324 Commits

Author SHA1 Message Date
Cooper Quintin
f2b722ad5f version update 2025-06-04 10:12:08 -07:00
Cooper Quintin
5e2058e7ac update make to use firmware profile 2025-06-04 10:12:08 -07:00
cooperq
60daf4b716 update win docs 2025-06-04 10:11:12 -07:00
Cooper Quintin
4df317b028 dig deeper in the json tree. Fixes #360 2025-06-04 10:10:18 -07:00
Sashanoraa
d7fb8b9c85 Move most serial commands to usb shared USB claim with adb 2025-06-04 09:11:06 -07:00
Markus Unterwaditzer
d399532494 Add documentation for key input 2025-06-03 14:29:04 -07:00
Cooper Quintin
45df91a364 Update using-rayhunter.md 2025-06-03 14:23:50 -07:00
Matej Kovacic
672ed8c6c6 Update using-rayhunter.md 2025-06-03 14:23:50 -07:00
Matej Kovacic
5c7c7cd766 Add files via upload 2025-06-03 14:21:19 -07:00
Markus Unterwaditzer
f41a8d38fe move analysis into diag reader thread as well 2025-06-03 13:58:47 -07:00
Markus Unterwaditzer
f9c8c4671e Add basic key input 2025-06-03 13:58:47 -07:00
Markus Unterwaditzer
723b20541e Move business logic out of axum handlers 2025-06-03 13:58:47 -07:00
cooperq
272a4aeabf update docs 2025-06-03 11:49:20 -07:00
cooperq
6ae70556ba fix windows powershell installer and rust installer root process 2025-06-03 11:49:20 -07:00
Markus Unterwaditzer
2915dea9e9 Remove dead code and default features 2025-05-30 10:21:30 -07:00
Markus Unterwaditzer
6941bc57b6 Fix issues on TP-Link v9
* Fix autostart by adding another port trigger for rayhunter-daemon
* Use Orbic's IOCTL params as fallback
* Fix sdcard path and make it configurable
* Update docs to indicate support
* Add uninstalling instructions for TP-Link
2025-05-30 10:14:51 -07:00
Cooper Quintin
5b9dd856a8 version bump 2025-05-30 10:13:34 -07:00
Cooper Quintin
5007cb0b36 cargo fmt 2025-05-30 10:13:34 -07:00
Cooper Quintin
1b244122df add adb shell command 2025-05-30 10:13:34 -07:00
Cooper Quintin
3c4cb56ce6 surpress error messages by default, fixes #347 2025-05-30 10:13:34 -07:00
Cooper Quintin
58843413b5 update adb version which fixes adb key missing error. Fixes #330 2025-05-30 10:13:34 -07:00
Cooper Quintin
4ee504fed7 bump version to 0.3.1 2025-05-28 17:31:58 -07:00
Matej Kovacic
894af5da0d Update installing-from-release.md 2025-05-27 08:48:49 -07:00
Matej Kovacic
d810e8e3c0 Update tplink-m7350.md 2025-05-27 08:45:57 -07:00
Markus Unterwaditzer
8755d5694c Fix installing from source
Due to recent build changes, --release no longer applies the right
optimization settings, and --profile firmware has to be used.
2025-05-27 08:36:00 -07:00
Caleb
70a7d81d05 Add spinning orca for loading 2025-05-23 15:01:56 -07:00
Caleb
c182543dfa Resize logo images 2025-05-23 15:01:56 -07:00
Caleb
056f4c02e5 Fix table overflowing 2025-05-23 15:01:56 -07:00
Caleb
237983a8cb Add logo 2025-05-23 15:01:56 -07:00
Cooper Quintin
9967f93af2 foo 2025-05-23 11:54:14 -07:00
Cooper Quintin
3358a06454 look for other orbic PID 2025-05-23 11:54:14 -07:00
cooperq
382702a9ee blah 2025-05-23 11:54:14 -07:00
cooperq
67c3eb7d91 experiment 2025-05-23 11:54:14 -07:00
cooperq
98b05bfdb0 add install.ps1 to release zip 2025-05-23 11:54:14 -07:00
cooperq
01d10b87b3 more bugfix 2025-05-23 11:54:14 -07:00
Cooper Quintin
410e902848 fix more issues with windows installer 2025-05-23 11:54:14 -07:00
cooperq
f03f9fcdae more bugfix 2025-05-23 11:54:14 -07:00
Markus Unterwaditzer
4b68c30ed3 add note about sleep 2025-05-23 11:02:18 -07:00
Markus Unterwaditzer
b5481331c2 add windows instructions for tplink 2025-05-23 11:02:18 -07:00
Markus Unterwaditzer
ace65a8e55 Update docs for tplink to make clear which versions don't work 2025-05-23 11:02:18 -07:00
Markus Unterwaditzer
920044a5b2 Mention quarantine bit on macOS
See #323
2025-05-23 10:53:05 -07:00
Sashanoraa
6cb9a195ed Add env_logger to installer
Some libraries, namely nusb, produce debug logging that we might want to
access in the field.
2025-05-23 10:43:14 -07:00
Markus Unterwaditzer
90e2bddbbb Make Delete All Recordings more robust against missing recordings
For some reason, some QMDL files are missing on my device. In this
situation Delete All Recordings should not fail on the first missing
file.
2025-05-19 11:02:25 -07:00
Sashanoraa
3fa583f671 Re-enable debug info and unwind for non-firmware binaries 2025-05-19 09:51:36 -07:00
Caleb
8e6b86b26f Add ability to get qmdl file size in user readable values 2025-05-19 09:49:44 -07:00
Caleb
d40d4fb9c1 Fix alignment for analysis views 2025-05-19 09:49:44 -07:00
Caleb
a12bc4075e Tweak UI and text 2025-05-19 09:49:44 -07:00
Caleb
51327917b0 Change system information table 2025-05-19 09:49:44 -07:00
Caleb
4982463b57 Add seperate card for current recording and recording controls 2025-05-19 09:49:44 -07:00
Caleb
68aafd41e1 Add arrow for analysis view toggle 2025-05-19 09:49:44 -07:00
Caleb
8b053a9ef8 Add pill css for warnings 2025-05-19 09:49:44 -07:00
Caleb
ace325a38a Fix alignment of Analysis view 2025-05-19 09:49:44 -07:00
Caleb
6d02731a81 Additional mobile manifest card UI tweaks 2025-05-19 09:49:44 -07:00
Caleb
69b7fecb17 Add links to github and docs 2025-05-19 09:49:44 -07:00
Caleb
279169257d Add card based manifest entries for mobile 2025-05-19 09:49:44 -07:00
Caleb
9a60e3f820 Add dropshadows and other small UI changes 2025-05-19 09:49:44 -07:00
Caleb
77e51ec2f6 Fix Table UI and ControlBar UI 2025-05-19 09:49:44 -07:00
Caleb
c9c92706bc Fix margins 2025-05-19 09:49:44 -07:00
Caleb
643fa9f979 Add header 2025-05-19 09:49:44 -07:00
Caleb
96a02763e4 Fix margins sizes based on screen size 2025-05-19 09:49:44 -07:00
Caleb
ff421de127 Fix max height of analysis view for better mobile experiance 2025-05-19 09:49:44 -07:00
Caleb
635c8a0188 Fix capitalization of version 2025-05-19 09:49:44 -07:00
Cooper Quintin
5b8a0ef8d4 Update installing-from-release.md 2025-05-19 09:31:00 -07:00
Caleb
757b053a33 [DOCS] Fix install extract instructions 2025-05-19 09:31:00 -07:00
oopsbagel
5d9bc27ac9 Merge pull request #319 from EFForg/powershell
fix issues with powershell installer
2025-05-17 00:02:05 +00:00
cooperq
7d45be4f0c fix issues with powershell installer 2025-05-16 16:50:07 -07:00
Cooper Quintin
d58881c1f5 Merge branch 'kevstewa-main' 2025-05-16 13:16:24 -07:00
Kevin Stewart
4e16c7f9ce Merge remote-tracking branch 'upstream' 2025-05-16 13:10:50 -07:00
Kevin Stewart
c6d0cccb76 Switch release artifact to zip with SHA256
This change updates the build_release_zip workflow job to create and
upload a .zip archive and its corresponding .sha256 checksum file
instead of a .tar archive.
2025-05-16 12:37:47 -07:00
Cooper Quintin
f2d32512aa bump installer version 2025-05-16 12:19:03 -07:00
Cooper Quintin
e463d40c07 bump version to 0.3.0 2025-05-16 12:19:03 -07:00
Markus Unterwaditzer
c8edacf1ed rootshell, and add missing --release 2025-05-16 11:59:39 -07:00
Markus Unterwaditzer
ce8260b92c Update documentation for Rust installer 2025-05-16 11:59:39 -07:00
Sashanoraa
d6e4f6a71d Always include firmware binaries statically into installer 2025-05-16 11:48:23 -07:00
Sashanoraa
a2269fb5f7 Clean up function names and fix clippy warning 2025-05-16 11:48:23 -07:00
Sashanoraa
1c4e9b8499 Switch to having the rev for adb_client in Cargo.toml 2025-05-16 11:48:23 -07:00
Sashanoraa
fce30a78a2 Add special case to avoid hang on macOS 2025-05-16 11:48:23 -07:00
Sashanoraa
6a16ad7f15 Add special case for PermissionDenied on macOS
On macOS this can mean the device is busy.
2025-05-16 11:48:23 -07:00
Sashanoraa
ec5bd81a70 Update adb_client, now with usb lib being a feature flag
This update also fixes libusb throwing timeouts when it shouldn't
2025-05-16 11:48:23 -07:00
Sashanoraa
fbce9c8b04 Update adb_client to usb libusb on window and macOS 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
92b825a9e3 reset language for v3 after installation 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
c285e2ca08 Various fixes for TP-Link
* explicitly mount the SD card to improve reliability
* do not crash when the SD card is already mounted
* address some review feedback
2025-05-16 11:48:23 -07:00
Sashanoraa
4a7452806d Update adb_client with session fix 2025-05-16 11:48:23 -07:00
Sashanoraa
2e85d4f186 Switch adb_client back to the rayhunter branch
We've confirmed nusb works so I've merged it into the rayhunter branch.
2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
e3acfe9144 Update documentation and remove old installer scripts 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
7418cc19b3 fix for tplink v5.2 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
cc72f1eabc fix clipppy 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
e071bc6619 Add basic installer for TP-Link v5 2025-05-16 11:48:23 -07:00
Sashanoraa
60015e0ff6 Add serial subcommand to installer 2025-05-16 11:48:23 -07:00
Sashanoraa
bbcf23899e Remove the "install-*" prefix from the install commands 2025-05-16 11:48:23 -07:00
Sashanoraa
c97212cdc8 Switch to read_exact in tp-link telnet_send_command 2025-05-16 11:48:23 -07:00
Sashanoraa
894f457751 Update adb_client to remove unneeded deps from tcp 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
da34c05364 Simplify the tplink installer
Found an exploit that requires fewer HTTP requests and can be run
without auth.
2025-05-16 11:48:23 -07:00
Sashanoraa
30d62b8d7b Add Orbic support for the Rust installer and some common improvements 2025-05-16 11:48:23 -07:00
Sashanoraa
1f7b7f0f1a Move serial into the installer in prep for Orbic support 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
da53ec9df2 move to tplink module 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
0beff5ea63 fix path 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
a946ebbe92 remove default features from hyper 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
64a87534ee fix up ci, build installer in actions 2025-05-16 11:48:23 -07:00
Markus Unterwaditzer
4a94545498 Tplink M7350 installer v3 in Rust
It does the same thing as https://github.com/EFForg/rayhunter/pull/272
but only installs necessary files. Installation happens entirely over
the network so there is no dependency on ADB.

Currently can be used like this:

1. cargo build --bin rayhunter-daemon --target armv7-unknown-linux-gnueabihf --release --no-default-features --features tplink
2. cp target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon dist/rayhunter-daemon-tplink
3. cargo run --bin installer -- install-tplink
2025-05-16 11:48:23 -07:00
Caleb
9e532ac975 Fix mismatch in padding from table header and table rows 2025-05-16 11:29:39 -07:00
Caleb
35e3c80313 Add RayHunter branding colors to TailwindCSS 2025-05-16 11:29:39 -07:00
Caleb
221c3591fd Change trashcan icon to white 2025-05-16 11:29:39 -07:00
Caleb
cf0061fe53 fix analysis collapsing 2025-05-16 11:29:39 -07:00
strasharo
5bd2909c0d Fix typo in SUMMARY.md 2025-05-14 10:14:55 -07:00
Kevin Stewart
3e1eb9d5e6 Create versioned release tarball
The release workflow now produces a tarball named
`rayhunter-v<version>.tar`, where the version is dynamically extracted
from `rayhunter/bin/Cargo.toml`. Additionally, the archive contains a
top-level directory named `rayhunter-v<version>/`, making each release
artifact clearly identifiable and self-contained by version. This change
improves clarity for downstream consumers and simplifies managing
multiple versions.
2025-05-13 15:12:03 -07:00
oopsbagel
adfe081eaf Merge pull request #309 from untitaker/tplink-doc
Add basic docs for TP-Link
2025-05-13 03:03:46 +00:00
Markus Unterwaditzer
f165dddd0c fix check mark on orbic.md 2025-05-13 01:22:55 +02:00
Markus Unterwaditzer
214375ead2 split out orbic too 2025-05-13 01:21:57 +02:00
Markus Unterwaditzer
0d4514a332 Add basic docs for TP-Link 2025-05-13 01:18:52 +02:00
oopsbagel
5180205144 doc: uninstalling.md: fix code block 2025-05-12 16:10:39 -07:00
Will Greenberg
5ed1a9bae3 rm broken doc link 2025-05-12 16:10:39 -07:00
Will Greenberg
abc3c07201 Migrate README content to the mdbook 2025-05-12 16:10:39 -07:00
oopsbagel
98ee6dacf8 doc(building bin/web): also run npm install 2025-05-12 13:01:15 -07:00
oopsbagel
a9f1284fa6 docs: publish mdbook in doc to github pages
This commit adds an mdbook for rayhunter documentation in `doc`, and
actions workflows to publish that documentation to github pages.

This commit configures actions to publish to pages via artifact uploads,
but but can be adjusted to publish based solely on a branch.[0]
This was chosen to allow for future flexibility in generating multiple
outputs (such as a single page html document or pdf).

[0] https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site
2025-05-12 13:00:45 -07:00
oopsbagel
d31bf45f95 Merge pull request #304 from EFForg/docs/update-frequency
[DOCS] Update README.md to Explicitly State Supported Bands
2025-05-06 03:25:50 +00:00
Alexis
8e8a28ae26 Update README.md to Explicitly State Supported Bands
Had to check around forums and docs just to find this info so just dumped here. Take it or leave it though if this exists somewhere else.

Also, areWiFi bands needed? Probably not but left it just in case.
2025-05-06 11:17:04 +09:00
oopsbagel
a7a5221c90 ci: remove duplicated ISSUE_TEMPLATE directory
We only need the files in .github/ISSUE_TEMPLATE
2025-04-28 17:31:00 -07:00
Cooper Quintin
469a716b7c add make script using docker 2025-04-28 17:19:46 -07:00
Cooper Quintin
c569101c36 Merge branch 'main' into frontend-rework 2025-04-28 17:19:28 -07:00
oopsbagel
b9945827c4 Merge pull request #298 from untitaker/ref/readme-orbic
Add disclaimer about country support
2025-04-28 19:28:32 +00:00
Markus Unterwaditzer
f97bc56f2c Add disclaimer about country support
More users are discovering this repo and buy Orbic devices for countries
where the device doesn't work.
2025-04-26 01:06:03 +02:00
oopsbagel
55ba316046 Merge pull request #297 from EFForg/wgreenberg-patch-1
unzip -> decompress
2025-04-25 19:21:39 +00:00
Will Greenberg
5ae6f0c5ce unzip -> decompress 2025-04-25 12:05:31 -07:00
Markus Unterwaditzer
7e1b410f89 add clippy to CI 2025-04-25 11:57:33 -07:00
Markus Unterwaditzer
32b67df55d Fix clippy lints and upgrade deku
Old version of deku was throwing clippy lints in generated code
2025-04-25 11:57:33 -07:00
oopsbagel
a8087c6840 cargo/config: show apt pkgs for gnueabihf 2025-04-25 11:55:23 -07:00
oopsbagel
f2028a704f tools: target armv7 musleabihf 2025-04-25 11:55:23 -07:00
oopsbagel
e04b78f0e0 ci: use rust-lld for all release targets
Removes dependency on gcc-based cross-compilation toolchain.
2025-04-25 11:55:23 -07:00
Will Greenberg
ece589331f bin: rm unused debug mode functionality
With the new svelte-based frontend, there's a better local debug mode
using `npm run dev`
2025-04-24 13:52:11 -07:00
Will Greenberg
b95ff90e5e cargo fmt 2025-04-24 13:23:29 -07:00
Will Greenberg
33745bc4e2 add rayhunter version to web UI, better row colors 2025-04-24 10:33:18 -07:00
Will Greenberg
73682240d6 fix more CI 2025-04-23 11:27:56 -07:00
Will Greenberg
43324c0ad7 add title and darken the green 2025-04-23 11:09:52 -07:00
Will Greenberg
f559e10d44 rm git detritus 2025-04-23 11:09:10 -07:00
Will Greenberg
f28022920a fix CI 2025-04-23 11:08:54 -07:00
Cooper Quintin
63b07b83f5 darker links 2025-04-22 16:37:35 -07:00
Cooper Quintin
934e0d70d8 change refresh time to 1sec 2025-04-22 16:27:57 -07:00
Cooper Quintin
769826dcea check if metadata exists and handle gracefully 2025-04-22 12:11:23 -07:00
Cooper Quintin
e4bfa7a1f3 Merge branch 'main' into frontend-rework 2025-04-22 11:41:51 -07:00
Tyler Cipriani
d95da9b382 README: clarify "unzip" instructions
The current `release.tar` (v0.2.7)  lacks a `release` directory -- all
files live at the root of the tar archive. But the README's Unzip
instructions mention `cd`ing to `~/Downloads/release`, which implies
that there is a `release` directory inside the tar.

Rather than verify with `tar --list --file ~/Downloads/release.tar` I
made a bad assumption, ran `tar xvf ./release.tar` in my `~/Downloads`,
and then had to clean up my `~/Downloads` directory.

This update clarifies that users should create the directory and extract
the tar into that directory.
2025-04-22 11:39:06 -07:00
Cooper Quintin
941ea59e11 I forgot rootshell and telecom parser 2025-04-22 11:04:42 -07:00
Cooper Quintin
8082e013f4 bump version 2025-04-22 11:04:42 -07:00
Cooper Quintin
f72194ab3e remove demo file 2025-04-22 10:12:45 -07:00
Cooper Quintin
3b1547c749 delete package lock and yarn lock from repo 2025-04-22 10:11:37 -07:00
Cooper Quintin
af17788a36 add package lock to gitignore 2025-04-22 10:10:41 -07:00
Cooper Quintin
1a8010964e Merge branch 'main' into frontend-rework 2025-04-22 10:09:26 -07:00
Cooper Quintin
a7ce1ad4d3 update mm invite 2025-04-18 12:41:25 -07:00
Markus Unterwaditzer
531e9aa6fb this documentation is also not useful to end users 2025-04-17 09:46:23 -07:00
Markus Unterwaditzer
833d0e41b4 more cleanup 2025-04-17 09:46:23 -07:00
Markus Unterwaditzer
056cdac546 remove Silicon from Mac, we do support intel 2025-04-17 09:46:23 -07:00
Markus Unterwaditzer
6ea2b0a4e6 Remove outdated instructions from README
`cargo test_pc` is not a thing, remove these instructions although there
currently is no replacement.
2025-04-17 09:46:23 -07:00
Will Greenberg
d3f70fee01 show informational logs, skipped reasons, and some formatting fixes 2025-04-16 14:31:16 -07:00
Will Greenberg
2ee4ab5082 update location of static images 2025-04-16 13:03:33 -07:00
Will Greenberg
7708efd0c9 Update README w/ frontend info, clarify some parts
Also fixes a typo in `install-dev.sh`
2025-04-16 13:02:54 -07:00
Will Greenberg
6b15f807df bring back images 2025-04-16 11:00:01 -07:00
Will Greenberg
0a1f9f4de1 rm unused import 2025-04-16 10:57:46 -07:00
Will Greenberg
fb1d550793 when deleting all, close the current recording first 2025-04-16 10:57:33 -07:00
Will Greenberg
2fc0144905 update make script to build site 2025-04-16 10:57:18 -07:00
Will Greenberg
fb1657676e rm old frontend code, add favicon 2025-04-15 18:21:58 -07:00
Will Greenberg
bb5c288c2f rm unused function 2025-04-15 18:21:10 -07:00
Will Greenberg
d63f419fbc parity with current UI 2025-04-15 18:08:18 -07:00
Will Greenberg
a33c7511eb better controls, formatting, etc 2025-04-14 20:05:00 -07:00
Markus Unterwaditzer
1cc5eb4c4c README: Do not mention SD card
Apparently SD card is not a thing on Orbic, only on TP-Link.
2025-04-14 16:42:09 -07:00
Will Greenberg
c4b2c3bbe2 better start/stop buttons 2025-04-14 15:52:51 -07:00
Will Greenberg
d9c58129ff longer poll period 2025-04-14 15:06:08 -07:00
Will Greenberg
41d3b4ed39 wip 2025-04-14 12:01:41 -07:00
Will Greenberg
4113b71baf fixed most svelte issues 2025-04-14 11:59:55 -07:00
Will Greenberg
4f0bc3ad93 update prerender location 2025-04-14 11:59:55 -07:00
Will Greenberg
cf2d406d88 wip 2025-04-14 11:59:55 -07:00
Will Greenberg
057c9acb40 wip 2025-04-14 11:59:54 -07:00
Will Greenberg
57b0455363 wip 2025-04-14 11:55:50 -07:00
Will Greenberg
fa96520fe5 wip 2025-04-14 11:54:27 -07:00
Will Greenberg
a269a45244 wip 2025-04-14 11:54:27 -07:00
oopsbagel
99676f1590 chore: add blame ignore rev file
Do not display formatting commit in git blame. Use this file locally by running:

git config blame.ignoreRevsFile .git-blame-ignore-revs

This file is read by github automatically.[0]

[0] https://docs.github.com/en/repositories/working-with-files/using-files/viewing-and-understanding-files#ignore-commits-in-the-blame-view
2025-04-14 11:49:24 -07:00
oopsbagel
9fe75ac961 chore: cargo fmt 2025-04-14 11:49:24 -07:00
Markus Unterwaditzer
151e186ef9 Fix delete all recordings, and panic on server startup
* Delete All Recordings did not work when recording was paused
* Because of the upgrade to axum 0.8, the webserver did not actually
  start but panic.
2025-04-11 12:42:23 -07:00
Cooper Quintin
06c4dd468e Merge branch 'untitaker-build-features' 2025-04-11 11:30:23 -07:00
Markus Unterwaditzer
740f979293 Merge remote-tracking branch 'origin/main' into build-features 2025-04-11 20:15:18 +02:00
oopsbagel
700258b0f2 ci: test build release on PRs 2025-04-11 11:13:07 -07:00
oopsbagel
f661e2e318 ci(windows): compile serial for x86_64-pc-windows-gnu 2025-04-11 11:13:07 -07:00
Markus Unterwaditzer
b12a159f0a Merge remote-tracking branch 'origin/main' into build-features 2025-04-11 19:57:15 +02:00
oopsbagel
4e40994577 ci: add windows target for serial 2025-04-11 10:42:29 -07:00
Cooper Quintin
1b29cf0dee Merge branch 'main' into build-features 2025-04-11 10:38:49 -07:00
Markus Unterwaditzer
aafd83d636 Upgrade axum to reduce binary size
For some reason upgrading axum to 0.8 reduces the binary size by 300kB
2025-04-11 10:32:02 -07:00
oopsbagel
dd67fbf645 ci: statically compile serial binary
fix unreleased rayhunter-check binary names
2025-04-11 10:30:38 -07:00
Markus Unterwaditzer
e440dab736 Add dockerfile for easier building on MacOS 2025-04-11 10:09:35 -07:00
oopsbagel
30e543898b ci: add windows-latest (x86_64) release 2025-04-11 10:07:02 -07:00
oopsbagel
01e762a3d6 fix(lib): enable building for windows targets
- conditionally build diag_device.rs only for unix
- use build time target for runtime metadata on unix
2025-04-11 10:07:02 -07:00
oopsbagel
fa9e9319c2 fix(serial.enable_command_mode): claim usb device interface
Windows does not support nusb::Device.control_out_blocking

Claim the interface before writing as required on Windows.
2025-04-11 10:07:02 -07:00
oopsbagel
b317200307 ci: add windows serial cargo check and test 2025-04-11 10:07:02 -07:00
Markus Unterwaditzer
55f78cf749 Document what the red line means
Fix https://github.com/EFForg/rayhunter/issues/134
2025-04-10 16:51:28 -07:00
Markus Unterwaditzer
cb9e8254a8 cargo fmt 2025-04-09 15:37:20 +02:00
Markus Unterwaditzer
a9afa347f0 turn pixelart macro into const expr 2025-04-09 15:37:03 +02:00
zoracon
75944a7d16 Fix template bugs 2025-04-08 15:53:27 -07:00
Markus Unterwaditzer
e11bb2518e fix tests 2025-04-08 21:33:41 +02:00
Markus Unterwaditzer
31076ec8b2 replace with exclamation mark 2025-04-08 21:24:33 +02:00
Markus Unterwaditzer
5e22b5c6a8 Update bin/src/display/tplink_onebit.rs
Co-authored-by: Will Greenberg <ifnspifn@gmail.com>
2025-04-08 21:21:36 +02:00
Markus Unterwaditzer
3dc373f0d3 add code comment 2025-04-08 21:21:20 +02:00
Markus Unterwaditzer
bccdcf36e1 Merge remote-tracking branch 'origin/main' into build-features 2025-04-08 21:16:08 +02:00
Will Greenberg
fb9c4ab85b Update pull_request_template.md 2025-04-08 09:57:23 -07:00
Will Greenberg
e864ce0a51 Add PR template 2025-04-08 09:57:23 -07:00
zoracon
7f990ae4bd Move issue templates to correct location 2025-04-08 09:56:13 -07:00
Sashanoraa
3ac4acd83c Indent rootshell's code to 4 space like everything else
It was three for some reason.
2025-04-08 08:59:40 -07:00
Markus Unterwaditzer
5c5333f0c7 Remove RecordingCBM
Colorblind mode is a property of the respective display, and decision
whether to display something in colorblind mode should lie with the
display thread. The display thread already needs to know about
colorblind mode for the initial state.

In #226, there are multiple implementations of display thread, and at
least one of them is dealing with a one-bit display anyway.

Aside, I think rayhunter should send an initial DisplayState on startup,
UI threads should not assume that the device is already recording. But
this can be discussed separately.
2025-04-08 08:58:08 -07:00
Sashanoraa
60934e593b Add the content length header to the qmdl file response 2025-04-08 08:54:39 -07:00
oopsbagel
4099eb30a5 ci: build on ubuntu-24.04-arm (aarch64) 2025-04-08 08:53:56 -07:00
Evan Rusmisel
f81adad897 rusty 2025-04-08 08:47:54 -07:00
dependabot[bot]
775468f037 Bump tokio from 1.44.1 to 1.44.2
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.44.1 to 1.44.2.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.44.1...tokio-1.44.2)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.44.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-08 08:44:46 -07:00
Will Greenberg
91e825adff mac installer: if we've already removed quarantine bit, continue 2025-04-07 16:16:54 -07:00
Markus Unterwaditzer
499b86aca6 Add build features for multiple device types
The bin crate now has two features, one for each supported device.

* The IOCTL change from #142 is compiled in conditionally.
* Tp-link display is supported & tested for HW rev 3 and HW rev 5.

The release tarballs now contain two rayhunter-daemon binaries, for
orbic and tplink. An installer for tplink is not yet included.

Co-authored-by: m0veax <m0veax@chaospott.de>
2025-04-07 21:15:32 +02:00
Sashanoraa
7b897c335d Remove unneeded image dependencies
This removes a bunch of dependencies we aren't using and reduces the
binary size by 0.7 MB.
2025-04-04 12:30:57 -07:00
Sashanoraa
c47be1074b Add delete all recordings button to web ui 2025-04-04 12:21:51 -07:00
Sashanoraa
326d4106bd Add a delete option to each recording in the web view 2025-04-04 12:21:51 -07:00
Markus Unterwaditzer
df8a1f5606 Fix RecordingStore::create
Bug introduced in #225
2025-04-03 15:22:08 -07:00
Markus Unterwaditzer
b0f5296c20 disable quirks mode 2025-04-03 12:15:33 -07:00
Markus Unterwaditzer
4e792b1402 Fix rendering of last_message_time in UI
* last_message_time is shown inconsistently for current entry vs other
  entries -- deduplicate code
* last_message_time is N/A for undefined -- but the API response was
  null instead of undefined.
2025-04-03 12:15:33 -07:00
zoracon
9144259202 Add issue templates 2025-04-03 12:14:15 -07:00
Markus Unterwaditzer
58f0071864 Fix malformed QMDL store writes
Fix https://github.com/EFForg/rayhunter/issues/199
Fix https://github.com/EFForg/rayhunter/issues/151

rayhunter updates manifest files using write **without truncation**.
This means that if the new manifest is shorter than the old one,
trailing bytes of the old data will persist in the new file.

Switch over to atomic file writes so that this bug is fixed + rayhunter
behaves correctly if it is killed mid-write.

https://github.com/EFForg/rayhunter/pull/182 could be reverted as it
seems to mostly be a workaround.
2025-04-03 12:09:19 -07:00
oopsbagel
3c0716c877 feat(serial): replace all panics with error handling from anyhow
Support -h and --help arguments.
Print a better error message when the Orbic device is not found.
Replace every panic! with anyhow::bail!
Replace .expect() with .context()?
Wraps all function returns with anyhow::Result
2025-04-01 13:40:01 -07:00
Jeremy Blanchard
bf8f1fb8eb Add troubleshooting step for macOS 2025-04-01 12:23:39 -07:00
Jeremy Blanchard
2a808245fb Fix anchor link for setup section 2025-04-01 12:23:39 -07:00
Jeremy Blanchard
208ccbafaa Fix another rebase merge issue 2025-04-01 12:23:39 -07:00
Jeremy Blanchard
b150f9dc4f Fix header issue from the rebase 2025-04-01 12:23:39 -07:00
Jeremy Blanchard
b6ef48e0f6 Corrent path of unzipped folder 2025-04-01 12:23:39 -07:00
Jeremy Blanchard
fddb18546c Unify install scripts in docs 2025-04-01 12:23:39 -07:00
Jeremy Blanchard
2911838b1c Cleanup language and formatting 2025-04-01 12:23:39 -07:00
Jeremy Blanchard
adbe3991dd Improve installation doc clarity. Cleanup capitalization. 2025-04-01 12:23:39 -07:00
Sashanoraa
fbc47187c5 Create ServerState outside of run_server
This reduces the number of arguments of run_server to make clippy happy
and also makes the code easier to understand.
2025-03-27 11:57:01 -07:00
Sashanoraa
5f601a209e Collapse nested if statements 2025-03-27 11:57:01 -07:00
Sashanoraa
04652d2097 Add implement Default on types with ::new
This fixes a clippy lint warning
2025-03-27 11:57:01 -07:00
Sashanoraa
034e0632e4 Box some of the larger information element enum variants
An enum is always the size needed to store its largest variant. Some of
the variants of the InformationElement and LteInformationElement are
substantially larger than the rest. Boxing the larger variants reduces
the size of the enum, in some cases by several kilobytes.

Since Rust does not currently support destructing a Box via pattern
matching, some code that destructures these enums had to be modified.
2025-03-27 11:57:01 -07:00
Sashanoraa
4edf001ca4 Fix small clippy warnings 2025-03-27 11:57:01 -07:00
Sashanoraa
b41f61bfa6 Replace unnecessary File::options with File::create 2025-03-27 11:57:01 -07:00
Will Greenberg
46a5bf8a84 Add signal link 2025-03-27 11:18:39 -07:00
Will Greenberg
2ee45382fc Update README.md 2025-03-27 11:18:39 -07:00
cycloarcane
f507cc0269 Added an FAQ entry on how to use the rootshell to delete data from the device 2025-03-27 09:52:19 -07:00
Tim Kerby
0780b527b9 Update rayhunter_daemon for TPLINK Compatibility
TPLink devices dont have bash - only sh
2025-03-26 10:41:15 -07:00
Sashanoraa
b0a1b14160 Remove unused import due to e79dc4a
The referenced commit disabled the null-cipher but did not remove the
now unused import.
2025-03-26 10:41:05 -07:00
Sashanoraa
b7243dae62 Add missing Cargo.lock updates from 0.2.6 version bump 2025-03-26 10:40:26 -07:00
Sashanoraa
0c4a0123aa Add missing Cargo.lock changes from 9af8e00 2025-03-26 10:40:26 -07:00
Will Greenberg
9bc8a7892b fix typo in installer script 2025-03-26 10:22:17 -07:00
oopsbagel
431a97ca65 chore: bump all Cargo.toml versions to 0.2.6 2025-03-25 17:02:01 -07:00
Will Greenberg
0364bfbc98 bump version number
we uhh forgot to do this for every release lol
2025-03-25 16:53:20 -07:00
Ben Brown
996e47684c Fix typo on readme
sensetive -> sensitive
2025-03-25 16:52:16 -07:00
Cooper Quintin
266f2b2e53 more nesting 2025-03-25 16:49:08 -07:00
Will Greenberg
2080cd7845 web ui: fix issue causing no entries
We weren't correctly handling all possible events from the heuristics
list
2025-03-25 16:49:08 -07:00
oopsbagel
9af8e006b0 fix(serial): use tokio's timeout with USB bulk in/out
Replace futures_lite::future::block_on (which will block indefinitely) with
tokio::time::timeout to restore the original behaviour of this utility, where
communication over USB interface bulk endpoints times out after 1 second.
2025-03-25 16:46:35 -07:00
oopsbagel
e841e22774 refactor(serial): replace rusb with nusb
nusb is a pure Rust library providing the same low level access to USB devices
that rusb/libusb provide.

This commit removes rusb (and thus the dependence on libusb) and replaces it
with nusb in the serial utility.

The only functional change is that nusb does not support timeouts for bulk data
commands. nusb is async. This commit contains a naïve implementation that simply
blocks on bulk reads and writes in send_command().
2025-03-25 16:46:35 -07:00
Will Greenberg
0d9f53f602 Update make.sh
reboot the orbic instead of starting up the process again, since rootshell seems to have insufficient privileges to start rayhunter
2025-03-25 16:34:23 -07:00
Will Greenberg
c9dcbbe5d6 daemon: if we fail to parse the QMDL manifest, make a new one
If rayhunter doesn't exit cleanly (e.g. during a battery outage), the
QMDL manifest may end up in a corrupted state. If that's the case,
rayhunter should try to recover by creating a new manifest. This'll let
it continue, and will preserve previous recordings, but they won't be
visible through the UI.
2025-03-25 15:36:12 -07:00
Will Greenberg
61d6ff6510 Add an update section 2025-03-25 15:14:54 -07:00
Will Greenberg
e79dc4a8f0 lib: diable null-cipher heuristic due to false positives
Due to an upstream hampi bug (https://github.com/ystero-dev/hampi/issues/133),
our RRC parser is reporting false-positives for the null cipher
heuristic.
2025-03-25 15:13:36 -07:00
Will Greenberg
6204bc0195 update installer script for macOS Intel 2025-03-24 16:42:58 -07:00
Will Greenberg
65b9843e39 test macOS intel builds 2025-03-24 16:42:58 -07:00
Sashanoraa
d0d01089dd Fix various clippy warnings
This commit fixes various clippy warnings that do not affect the
function of the code and aren't stylistic in nature.
2025-03-24 13:47:20 -07:00
Sashanoraa
9c26e89b24 Modify config load to use serde default
This commit refactors the config loading code to no longer require a
separate ConfigFile struct by taking advantage of serde's `default`
attribute. This causes serde to use the Config struct's default value
for that attribute for any missing attributes, which is what the
existing code was doing anyway.

This also fixes several clippy warnings.

Serde docs: https://serde.rs/container-attrs.html#default
2025-03-24 13:47:20 -07:00
Sashanoraa
1f4786db19 Have rootshell print errors and exit 1 if exec fails
Previously was ignoring the possible error retuned by exec, this commit
has rootshell print the error if exec returns and have the process exit
with a code of 1 instead of 0.
2025-03-24 13:47:20 -07:00
Kirk Strauser
88f81d86fa Remove the quarantine bit from the serial command on macOS 2025-03-20 10:49:07 -07:00
oopsbagel
0b3c0de481 fix(lib/util): use better names for runtime metadata
- document RuntimeMetadata fields
- rename RayhunterMetadata to RuntimeMetadata
- rename RuntimeMetadata.os to RuntimeMetadata.system_os
- remove unpopulated hardware field
- remove unnecessary duplication of datastructure in analyzer harness
2025-03-19 11:48:54 -07:00
oopsbagel
188e9f436b fix(qmdl-manifest): store os/arch/hardware in qmdl manifest.toml
Do not superfluously prefix these names with rayhunter_, as they describe the
hardware and not the binary.
2025-03-19 11:48:54 -07:00
oopsbagel
f2b5aa2743 feat: show rayhunter version/os/arch in pcap, ndjson, qmdl manifest
Create a util mod to provide information about the rayhunter binary and
system.
2025-03-19 11:48:54 -07:00
oopsbagel
b785a7f21c feat(qmdl): add rayhunter version and os to manifest.toml 2025-03-19 11:48:54 -07:00
oopsbagel
09d35ccec7 feat(pcap): add operating system kernel name and release
Display the uname sysname and release as the OS option in the pcap Section
Header Block, falling back on just the std::env::consts::OS name ("linux") in
the case of runtime errors.

Co-authored-by: Nat Budin <natbudin@gmail.com>
2025-03-19 11:48:54 -07:00
oopsbagel
5ae186bc73 feat(pcap): add rayhunter name and version to metadata
Add the compile-time name and version to the pcap's Section Header Block
as the shb_userappl option, the canonical place for storing the name of
the application used to create the pcap.[0]

[0] https://ietf-opsawg-wg.github.io/draft-ietf-opsawg-pcap/draft-ietf-opsawg-pcapng.html#section-4.1-10
2025-03-19 11:48:54 -07:00
Inhishonor
c765a40426 Improve grammer. 2025-03-19 09:27:01 -07:00
Inhishonor
93cfbea361 Fix various sentences in README. 2025-03-19 09:27:01 -07:00
Cooper Quintin
8e6bed97b7 Merge branch 'allpoints-132_Merge_OS_variant_install_scripts' 2025-03-18 18:22:33 -07:00
Cooper Quintin
4214b27c0f fix nits in install.sh and update readme with new instructions 2025-03-18 18:21:43 -07:00
rbomze
f69487853a minimized the binary size 2025-03-18 17:59:07 -07:00
Jeremy
7eb61748d7 Update readme: Add link to PGP key for contact email address 2025-03-18 17:59:07 -07:00
Will Greenberg
ca4e560e92 Update README.md 2025-03-18 17:59:07 -07:00
Alexis
2ffb1d4620 Update SECURITY.md
Just fixing the relative link to this project
2025-03-18 17:59:07 -07:00
Cooper Quintin
77944dd17c add security file 2025-03-18 17:59:07 -07:00
rbomze
50301076f0 minimized the binary size 2025-03-18 17:37:24 -07:00
Jeremy
21c839678b Update readme: Add link to PGP key for contact email address 2025-03-17 11:24:19 -07:00
Will Greenberg
332a7ffbd0 Update README.md 2025-03-12 11:56:12 -07:00
Alexis
8d250553b7 Update SECURITY.md
Just fixing the relative link to this project
2025-03-11 15:35:47 -07:00
Cooper Quintin
fa897e73fa add security file 2025-03-11 14:53:28 -07:00
Paul Beltrani
c3494e338f Merge install scripts into a single, isntall.sh 2025-03-09 22:27:48 -04:00
Cooper Quintin
f9b2cd6a59 add link to code of conduct 2025-03-07 11:40:37 -08:00
Will Greenberg
eb072fb38c fix various typos 2025-03-07 11:28:29 -08:00
Will Greenberg
91f82fc71d add curl to apt install list 2025-03-07 11:21:36 -08:00
Will Greenberg
6fda8450dc a few more FAQ adjustments 2025-03-07 11:21:36 -08:00
Cooper Quintin
bbfe5877fe More FAQ work 2025-03-07 11:21:36 -08:00
Will Greenberg
75d3740f66 Add FAQ to readme 2025-03-07 11:21:36 -08:00
oopsbagel
94c576fd96 fix(tools): add pycrate dependency to requirements.txt
nasparse.py and nasparse_test.py require the pycrate_mobile and
pycrate_core libraries provided by the pycrate package.

This commit adds the required package to requirements.txt.
2025-03-07 11:08:20 -08:00
Cooper Quintin
ee83613757 update readme 2025-02-27 17:29:48 -08:00
Cooper Quintin
840f8ad8b0 stop before upload in case file is locked from writing by running process 2025-02-10 11:26:27 -08:00
Cooper Quintin
c9ac834ca7 show warnings in web UI 2025-02-10 11:26:27 -08:00
Cooper Quintin
8629aacf6b switch default to not see trace messages, switch arg from quiet to verbose 2025-02-10 11:26:27 -08:00
Cooper Quintin
a3fd1479f9 rename qmdl path so that downloaded files have a qmdl extension 2025-02-10 11:26:27 -08:00
Cooper Quintin
049c563f02 fix shortcodes on rayhunter_check 2025-02-10 11:26:27 -08:00
Cooper Quintin
a33b5a3418 Update README.md
Co-authored-by: Will Greenberg <willg@eff.org>
2025-01-31 17:00:44 -08:00
Cooper Quintin
107ba58296 warn if running install scritps from git tree 2025-01-31 17:00:44 -08:00
Cooper Quintin
d016279172 some tweaks to readme 2025-01-31 17:00:44 -08:00
Will Greenberg
5a084f1abb lib: set uplink flag for NAS 2025-01-30 11:33:14 -08:00
Will Greenberg
3619df32ab check: give qmdl-path a shorthand arg 2025-01-28 11:02:19 -08:00
Will Greenberg
34d87d1fd7 this macro isn't public, so docstrings won't work 2025-01-28 11:02:19 -08:00
Will Greenberg
da4952e70f fix docstring code 2025-01-28 11:02:19 -08:00
Will Greenberg
30323b8329 Keep old 2G downgrade analyzer 2025-01-28 11:02:19 -08:00
Will Greenberg
28b0f409db fix attribution 2025-01-28 11:02:19 -08:00
Will Greenberg
12640cc878 Rewrite our 2G downgrade analyzer 2025-01-28 11:02:19 -08:00
Will Greenberg
26eda5904f Better wording on IMSI requested warning 2025-01-28 11:02:19 -08:00
Will Greenberg
3e26e61b05 check: don't count informational events as warnings, better logging 2025-01-28 11:02:19 -08:00
Will Greenberg
565c0f1e67 serial: fix UTF-8 panic on macOS 2025-01-26 17:05:42 -08:00
Will Greenberg
6bd36921d8 consider early IMSI request medium sev 2025-01-08 15:23:59 -08:00
Will Greenberg
c83ae30be8 fix language 2025-01-08 15:23:59 -08:00
Will Greenberg
fa612241a5 lib: add IMSI requested heuristic 2025-01-08 15:23:59 -08:00
Will Greenberg
10592bbd9d lib: add inbound/outbound field to NAS 2025-01-06 16:24:11 -08:00
Will Greenberg
327eaddcd7 rayhunter-check: pcapify qmdl 2025-01-06 16:24:11 -08:00
Will Greenberg
32149c3b37 Update tools/nasparse.py 2024-12-17 14:46:31 -08:00
Cooper Quintin
e47d4dacc4 raise error on non nas message 2024-12-17 14:46:31 -08:00
Cooper Quintin
4009e3d1ed fix nits 2024-12-17 14:46:31 -08:00
Cooper Quintin
b2cd735a07 proof of concept pcap reader for nas heuristic 2024-12-17 14:46:31 -08:00
Cooper Quintin
94e9a88a91 PoC of python nas heuristic 2024-12-17 14:46:31 -08:00
Cooper Quintin
f4a6c834d2 remove false positive IMSI heuristic until we get a NAS parser 2024-12-09 10:53:58 -08:00
Cooper Quintin
95e8f846d3 propegate colorblind mode beyond start/stop 2024-11-26 11:05:13 -08:00
Cooper Quintin
15f128add1 remove unneeded import 2024-11-26 11:05:13 -08:00
Cooper Quintin
87f9cc403b add colorblind mode. Fixes #77 2024-11-26 11:05:13 -08:00
Cooper Quintin
7addf3a67f fix reboot timeout 2024-11-18 17:10:16 -08:00
Cooper Quintin
4d8cc9b738 Revert "name binary rayhunter-daemon"
This reverts commit 9cd5ce3394.
2024-11-18 16:16:43 -08:00
Cooper Quintin
b0d797d206 name binary rayhunter-daemon 2024-11-18 16:16:43 -08:00
Will Greenberg
1ae3b5020b fix installer script
With the odd permissions issues we've been seeing, we should use
AT_SYSCMD for all mv operations into /data
2024-11-18 16:16:43 -08:00
153 changed files with 7864 additions and 2433 deletions

View File

@@ -1,3 +1,41 @@
[target.aarch64-apple-darwin]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
# apt install build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf
[target.armv7-unknown-linux-gnueabihf] [target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc" linker = "arm-linux-gnueabihf-gcc"
rustflags = ["-C", "target-feature=+crt-static"] rustflags = ["-C", "target-feature=+crt-static"]
[target.armv7-unknown-linux-musleabihf]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
# Disable rust-lld for x86 macOS because the linker crashers when compiling
# the installer in release mode with debug info on.
# [target.x86_64-apple-darwin]
# linker = "rust-lld"
# rustflags = ["-C", "target-feature=+crt-static"]
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
# keep line numbers in stack traces for non-firmware binaries
[profile.release]
debug = "limited"
# optimizations to reduce the binary size of firmware binaries
[profile.firmware]
inherits = "release"
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
debug = false

1
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1 @@
c5bbaabe15d4ccfee97b9997a13569fbfea13c45

59
.github/ISSUE_TEMPLATE/bug.yaml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Bug Report
description: File a bug report.
title: "[Bug]: "
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
attributes:
label: Rayhunter Version
description: |
Which version did you install?
placeholder: "v0.2.6"
- type: input
attributes:
label: Capture Date
description: |
YYYY-MM-DD
placeholder: "2025-05-01"
validations:
required: true
- type: input
attributes:
label: Capture Location
description: |
(If comfortable disclosing) What region or country were you in?
placeholder: Washington State
- type: input
attributes:
label: Device and Model
description: |
Device you installed Rayhunter on to.
placeholder: Orbic RC400L
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: |
What steps did you take to get to your issue?
placeholder: "Tell us what you see!"
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: Rayhunter's behavior differed from what I expected because.
placeholder: "What was expected?"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Rayhunter data captures (QMDL and PCAP logs) or error codes
render: shell

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Rayhunter Mattermost
url: https://opensource.eff.org/signup_user_complete/?id=6iqur37ucfrctfswrs14iscobw&md=link&sbr=su
about: If you're having trouble using Rayhunter and aren't sure you've found a bug or request for a new feature, please first try asking for help here. There is a much larger community there of people familiar with the project who will be able to more quickly answer your questions.
- name: Rayhunter Security Policy
url: https://github.com/EFForg/rayhunter/security/advisories/new
about: Please report security vulnerabilities here.

27
.github/ISSUE_TEMPLATE/feature.yaml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Feature Request
description: Suggest a new feature or improvement to Rayhunter
title: "[Feature Request]: "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: What problem does this feature solve or what does it enhance?
description: Explain what this feature addresses, ors the benefit it provides.
placeholder: For example, "Currently, users have to manually do X, which is time-consuming."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like to see implemented.
placeholder: For example, "Implement a new button that automatically does X."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions?
placeholder: For example, "We considered Y, but Z is a better approach because..."

6
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,6 @@
## Pull Request Checklist
- [ ] The Rayhunter team has recently expressed interest in reviewing a PR for this. If not, this PR may be closed due our limited resources and need to prioritize how we spend them.
- [ ] 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

View File

@@ -3,83 +3,160 @@ name: Build Release
on: on:
push: push:
branches: [main, "release-*"] branches: [main, "release-*"]
pull_request:
branches: ["main"]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
FILE_ROOTSHELL: ../../rootshell/rootshell
FILE_RAYHUNTER_DAEMON_ORBIC: ../../rayhunter-daemon-orbic/rayhunter-daemon
FILE_RAYHUNTER_DAEMON_TPLINK: ../../rayhunter-daemon-tplink/rayhunter-daemon
jobs: jobs:
build_serial_and_check: build_rayhunter_check:
strategy: strategy:
matrix: matrix:
platform: platform:
- os: ubuntu-latest - name: ubuntu-24
serial_build_name: serial os: ubuntu-latest
check_build_name: rayhunter-check target: x86_64-unknown-linux-musl
- os: macos-latest - name: ubuntu-24-aarch64
serial_build_name: serial os: ubuntu-24.04-arm
check_build_name: rayhunter-check target: aarch64-unknown-linux-musl
- name: macos-arm
os: macos-latest
target: aarch64-apple-darwin
- name: macos-intel
os: macos-13
target: x86_64-apple-darwin
- name: windows-x86_64
os: windows-latest
target: x86_64-pc-windows-gnu
runs-on: ${{ matrix.platform.os }} runs-on: ${{ matrix.platform.os }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build serial - name: Build rayhunter-check
run: cargo build --bin serial --release
- uses: actions/upload-artifact@v4
with:
name: serial-${{ matrix.platform.os }}
path: ./target/release/${{ matrix.platform.serial_build_name }}
if-no-files-found: error
- uses: actions/checkout@v4
- name: Build check
run: cargo build --bin rayhunter-check --release run: cargo build --bin rayhunter-check --release
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: rayhunter-check-${{ matrix.platform.os }} name: rayhunter-check-${{ matrix.platform.name }}
path: ./target/release/${{ matrix.platform.check_build_name }} path: target/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error if-no-files-found: error
build_rootshell_and_rayhunter: build_rootshell:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
targets: armv7-unknown-linux-gnueabihf targets: armv7-unknown-linux-musleabihf
- name: Install cross-compilation dependencies
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf
version: 1.0
- name: Build rootshell (arm32) - name: Build rootshell (arm32)
run: cargo build --bin rootshell --target armv7-unknown-linux-gnueabihf --release run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: rootshell name: rootshell
path: target/armv7-unknown-linux-gnueabihf/release/rootshell path: target/armv7-unknown-linux-musleabihf/firmware/rootshell
if-no-files-found: error if-no-files-found: error
build_rayhunter:
strategy:
matrix:
device:
- name: tplink
- name: orbic
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- name: Build rayhunter-daemon (arm32) - name: Build rayhunter-daemon (arm32)
run: cargo build --bin rayhunter-daemon --target armv7-unknown-linux-gnueabihf --release run: |
pushd bin/web
npm install
npm run build
popd
# Run with -p so that cargo will select the minimum feature set for this package.
#
# Otherwise, it will consider the union of all requested features
# from all packages in the workspace. For example, if installer
# requires tokio with "full" feature, it will be included no matter
# 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 --no-default-features --features ${{ matrix.device.name }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: rayhunter-daemon name: rayhunter-daemon-${{ matrix.device.name }}
path: target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon path: target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon
if-no-files-found: error if-no-files-found: error
build_rust_installer:
needs:
- build_rayhunter
strategy:
matrix:
platform:
- name: ubuntu-24
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- name: ubuntu-24-aarch64
os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
- name: macos-arm
os: macos-latest
target: aarch64-apple-darwin
- name: macos-intel
os: macos-13
target: x86_64-apple-darwin
- name: windows-x86_64
os: windows-latest
target: x86_64-pc-windows-gnu
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- run: cargo build --bin installer --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
name: installer-${{ matrix.platform.name }}
path: target/${{ matrix.platform.target }}/release/installer${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
build_release_zip: build_release_zip:
needs: needs:
- build_serial_and_check - build_rayhunter_check
- build_rootshell_and_rayhunter - build_rootshell
- build_rayhunter
- build_rust_installer
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
- name: Fix executable permissions on binaries - name: Fix executable permissions on binaries
run: chmod +x serial-*/serial rayhunter-check-*/rayhunter-check rayhunter-daemon/rayhunter-daemon run: chmod +x installer-*/installer rayhunter-check-*/rayhunter-check rayhunter-daemon-*/rayhunter-daemon
- name: Setup release directory - name: Get Rayhunter version
run: mv rayhunter-daemon/rayhunter-daemon rootshell/rootshell serial-* dist id: get_version
- name: Archive release directory run: echo "VERSION=$(grep '^version' bin/Cargo.toml | head -n 1 | cut -d'"' -f2)" >> $GITHUB_ENV
run: tar -cvf release.tar -C dist . - name: Setup versioned release directory
run: |
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
mkdir "$VERSIONED_DIR"
mv rayhunter-daemon-* rootshell/rootshell installer-* dist/* installer/install.ps1 "$VERSIONED_DIR"/
- name: Archive release directory as zip
run: |
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
zip -r "$VERSIONED_DIR.zip" "$VERSIONED_DIR"
- name: Compute SHA256 of zip
run: |
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
sha256sum "$VERSIONED_DIR.zip" > "$VERSIONED_DIR.zip.sha256"
# TODO: have this create a release directly # TODO: have this create a release directly
- name: Upload release - name: Upload zip release and sha256
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: release.tar name: rayhunter-v${{ env.VERSION }}
path: release.tar path: |
rayhunter-v${{ env.VERSION }}.zip
rayhunter-v${{ env.VERSION }}.zip.sha256
if-no-files-found: error if-no-files-found: error

View File

@@ -8,13 +8,47 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
NO_FIRMWARE_BIN: true
jobs: jobs:
check_and_test: check_and_test:
strategy:
matrix:
device:
- name: tplink
- name: orbic
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Check - name: Check
run: cargo check --verbose run: |
pushd bin/web
npm install
npm run build
popd
cargo check --verbose --no-default-features --features=${{ matrix.device.name }}
- name: Run tests - name: Run tests
run: cargo test --verbose run: |
pushd bin/web
npm install
npm run build
popd
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
- name: Run clippy
run: cargo clippy --verbose --no-default-features --features=${{ matrix.device.name }}
windows_installer_check_and_test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: cargo check
shell: bash
run: |
cd installer
cargo check --verbose
- name: cargo test
shell: bash
run: |
cd installer
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}

47
.github/workflows/mdbook.yaml vendored Normal file
View File

@@ -0,0 +1,47 @@
# On Repository Settings > Pages > Build and deployment
# Set "Source" to GitHub Actions.
name: Documentation
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
mdbook_test:
name: Test mdBook Documentation builds
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Test mdBook
run: mdbook test
mdbook_publish:
if: ${{ github.event_name != 'pull_request' }}
needs: mdbook_test
permissions:
pages: write
contents: write
id-token: write
name: Publish mdBook to Github Pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Build mdBook
run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: book
- name: Deploy to Github Pages
uses: actions/deploy-pages@v4

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/target /target
/book
.DS_Store

1
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1 @@
This project is governed by [EFF's Public Projects Code of Conduct](https://www.eff.org/pages/eppcode).

2348
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,8 @@
members = [ members = [
"lib", "lib",
"bin", "bin",
"serial",
"rootshell", "rootshell",
"telcom-parser", "telcom-parser",
"installer",
] ]
resolver = "2" resolver = "2"

View File

@@ -1,97 +1,7 @@
![Rayhunter Logo - An Orca taking a bite out of a cellular signal bar](https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png)
# Rayhunter # Rayhunter
```
@@@@@@@ @@@@@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@@@@@ @@@@@@@@ @@@@@@@
@@! @@@ @@! @@@ @@! !@@ @@! @@@ @@! @@@ @@!@!@@@ @@! @@! @@! @@@
@!@!!@! @!@!@!@! !@!@! @!@!@!@! @!@ !@! @!@@!!@! @!! @!!!:! @!@!!@!
!!: :!! !!: !!! !!: !!: !!! !!: !!! !!: !!! !!: !!: !!: :!!
: : : : : : .: : : : :.:: : :: : : : :: ::: : : :
_ _ _ _ _ _ _ _
)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_
O .
O ' '
o ' .
o .'
__________.-' '...___
.-' ### '''...__
/ a### ## ''--.._ ______
'. # ######## ' .-'
'-._ ..**********#### ___...---'''\ '
'-._ __________...---''' \ l
\ | apc '._|
\__;
```
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/check-and-test.yml/badge.svg) ![Tests](https://github.com/EFForg/rayhunter/actions/workflows/check-and-test.yml/badge.svg)
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
**THIS CODE IS PROOF OF CONCEPT AND SHOULD NOT BE RELIED UPON IN HIGH RISK SITUATIONS**
Code is built and tested for the Orbic RC400L mobile hotspot, it may work on other orbics and other
linux/qualcom devices but this is the only one we have tested on. Buy the orbic [using bezos bucks](https://www.amazon.com/gp/product/B09CLS6Z7X/)
## Setup
*NOTE: We don't currently support automated installs on windows, you will have to follow the manual install instructions below*
1. Download the latest [rayhunter release bundle](https://github.com/EFForg/rayhunter/releases) and extract it.
2. Run the install script inside the bundle corresponding to your platform (`install-linux.sh`, `install-mac.sh`).
3. Once finished, rayhunter should be running! You can verify this by visiting the web UI as described below.
## Usage
Once installed, rayhunter will run automatically whenever your Orbic device is running. It serves a web UI that provides some basic controls, such as being able to start/stop recordings, download captures, and view heuristic analyses of captures. You can access this UI in one of two ways:
1. Over wifi: Connect your phone/laptop to the Orbic's wifi network and visit `http://192.168.1.1:8080` (click past your browser warning you about the connection not being secure, rayhunter doesn't have HTTPS yet!)
* Note that you'll need the Orbic's wifi password for this, which can be retrieved by pressing the "MENU" button on the device and opening the 2.4 GHz menu.
2. Over usb: Connect the Orbic device to your laptop via usb. Run `adb forward tcp:8080 tcp:8080`, then visit `http://localhost:8080`. For this you will need to install the Android Debug Bridge (ADB) on your computer, you can copy the version that was downloaded inside the releases/platform-tools/` folder to somewhere else in your path or you can install it manually. You can find instructions for doing so on your platform [here](https://www.xda-developers.com/install-adb-windows-macos-linux/#how-to-set-up-adb-on-your-computer), (don't worry about instructions for installing it on a phone/device yet).
## Development
* Install ADB on your computer using the instructions above, and make sure it's in your terminal's PATH
* You can verify if ADB is in your PATH by running `which adb` in a terminal. If it prints the filepath to where ADB is installed, you're set! Otherwise, try following one of these guides:
* [linux](https://askubuntu.com/questions/652936/adding-android-sdk-platform-tools-to-path-downloaded-from-umake)
* [macOS](https://www.repeato.app/setting-up-adb-on-macos-a-step-by-step-guide/)
* [Windows](https://medium.com/@yadav-ajay/a-step-by-step-guide-to-setting-up-adb-path-on-windows-0b833faebf18)
### If your are on x86 linux
* on your linux laptop install rust the usual way and then install cross compiling dependences.
* run `sudo apt install build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf`
* set up cross compliing for rust:
```
rustup target add x86_64-unknown-linux-gnu
rustup target add armv7-unknown-linux-gnueabihf
```
Now you can root your device and install rayhunter by running `./tools/install-dev.sh`
### If you are 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 for arm using `cargo build`
* Run tests using `cargo test_pc`
* 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.example` to /data/rayhunter/config.toml
* Then run `./make.sh` this will build the binary and push it over adb. Restart your device or run `/etc/init.d/rayhunter_daemon start` on the device and you are good to go.
* Write your code and write tests
* Build for arm using `cargo build`
* Run tests using `cargo test_pc`
* push to the device with `./make.sh`
## Documentation
* Build docs locallly using `RUSTDOCFLAGS="--cfg docsrs" cargo doc --no-deps --all-features --open`
**LEGAL DISCLAIMER:** Use this program at your own risk. We beilieve 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!*

5
SECURITY.md Normal file
View File

@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Security vulnerabilities can be reported using GitHub's [private vulnerability reporting tool](https://github.com/EFForg/rayhunter/security/advisories/new).

View File

@@ -1,8 +1,15 @@
[package] [package]
name = "rayhunter-daemon" name = "rayhunter-daemon"
version = "0.1.0" version = "0.3.3"
edition = "2021" edition = "2021"
[features]
# These feature flags are mutually exclusive, and exactly one must be enabled.
orbic = ["rayhunter/orbic"]
tplink = ["rayhunter/tplink"]
default = ["orbic"]
[[bin]] [[bin]]
name = "rayhunter-daemon" name = "rayhunter-daemon"
path = "src/daemon.rs" path = "src/daemon.rs"
@@ -15,20 +22,21 @@ path = "src/check.rs"
rayhunter = { path = "../lib" } rayhunter = { path = "../lib" }
toml = "0.8.8" toml = "0.8.8"
serde = { version = "1.0.193", features = ["derive"] } serde = { version = "1.0.193", features = ["derive"] }
tokio = { version = "1.35.1", features = ["full"] } tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
axum = "0.7.3" axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
futures-core = "0.3.30"
thiserror = "1.0.52" thiserror = "1.0.52"
libc = "0.2.150"
log = "0.4.20" log = "0.4.20"
env_logger = "0.10.1" env_logger = { version = "0.11", default-features = false }
tokio-util = { version = "0.7.10", features = ["rt"] } tokio-util = { version = "0.7.10", features = ["rt", "io"] }
futures-macro = "0.3.30" futures-macro = "0.3.30"
include_dir = "0.7.3" include_dir = "0.7.3"
mime_guess = "2.0.4" mime_guess = "2.0.4"
chrono = { version = "0.4.31", features = ["serde"] } chrono = { version = "0.4.31", features = ["serde"] }
tokio-stream = "0.1.14" tokio-stream = { version = "0.1.14", default-features = false }
futures = "0.3.30" futures = { version = "0.3.30", default-features = false }
clap = { version = "4.5.2", features = ["derive"] } clap = { version = "4.5.2", features = ["derive"] }
serde_json = "1.0.114" serde_json = "1.0.114"
image = "0.25.1" image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
tempfile = "3.10.1" tempfile = "3.10.1"
simple_logger = "5.0.0"

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -18,9 +18,9 @@ use tokio::sync::mpsc::Receiver;
use tokio::sync::{RwLock, RwLockWriteGuard}; use tokio::sync::{RwLock, RwLockWriteGuard};
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use crate::dummy_analyzer::TestAnalyzer;
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::RecordingStore;
use crate::server::ServerState; use crate::server::ServerState;
use crate::dummy_analyzer::TestAnalyzer;
pub struct AnalysisWriter { pub struct AnalysisWriter {
writer: BufWriter<File>, writer: BufWriter<File>,
@@ -53,7 +53,10 @@ impl AnalysisWriter {
// Runs the analysis harness on the given container, serializing the results // Runs the analysis harness on the given container, serializing the results
// to the analysis file and returning the file's new length. // to the analysis file and returning the file's new length.
pub async fn analyze(&mut self, container: MessagesContainer) -> Result<(usize, bool), std::io::Error> { pub async fn analyze(
&mut self,
container: MessagesContainer,
) -> Result<(usize, bool), std::io::Error> {
let row = self.harness.analyze_qmdl_messages(container); let row = self.harness.analyze_qmdl_messages(container);
if !row.is_empty() { if !row.is_empty() {
self.write(&row).await?; self.write(&row).await?;
@@ -77,14 +80,32 @@ impl AnalysisWriter {
} }
} }
#[derive(Debug, Serialize, Clone, Default)] #[derive(Debug, Serialize, Clone)]
pub struct AnalysisStatus { pub struct AnalysisStatus {
queued: Vec<String>, queued: Vec<String>,
running: Option<String>, running: Option<String>,
finished: Vec<String>,
}
impl AnalysisStatus {
pub fn new(store: &RecordingStore) -> Self {
let existing_recordings: Vec<String> = store
.manifest
.entries
.iter()
.map(|entry| entry.name.clone())
.collect();
AnalysisStatus {
queued: Vec::new(),
running: None,
finished: existing_recordings,
}
}
} }
pub enum AnalysisCtrlMessage { pub enum AnalysisCtrlMessage {
NewFilesQueued, NewFilesQueued,
RecordingFinished(String),
Exit, Exit,
} }
@@ -100,9 +121,10 @@ async fn dequeue_to_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) -
name name
} }
async fn clear_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) { async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) {
let mut analysis_status = analysis_status_lock.write().await; let mut analysis_status = analysis_status_lock.write().await;
analysis_status.running = None; let finished = analysis_status.running.take().unwrap();
analysis_status.finished.push(finished);
} }
async fn perform_analysis( async fn perform_analysis(
@@ -114,7 +136,7 @@ async fn perform_analysis(
let (analysis_file, qmdl_file, entry_index) = { let (analysis_file, qmdl_file, entry_index) = {
let mut qmdl_store = qmdl_store_lock.write().await; let mut qmdl_store = qmdl_store_lock.write().await;
let (entry_index, _) = qmdl_store let (entry_index, _) = qmdl_store
.entry_for_name(&name) .entry_for_name(name)
.ok_or(format!("failed to find QMDL store entry for {}", name))?; .ok_or(format!("failed to find QMDL store entry for {}", name))?;
let analysis_file = qmdl_store let analysis_file = qmdl_store
.clear_and_open_entry_analysis(entry_index) .clear_and_open_entry_analysis(entry_index)
@@ -182,12 +204,19 @@ pub fn run_analysis_thread(
let count = queued_len(analysis_status_lock.clone()).await; let count = queued_len(analysis_status_lock.clone()).await;
for _ in 0..count { for _ in 0..count {
let name = dequeue_to_running(analysis_status_lock.clone()).await; let name = dequeue_to_running(analysis_status_lock.clone()).await;
if let Err(err) = perform_analysis(&name, qmdl_store_lock.clone(), enable_dummy_analyzer).await { if let Err(err) =
perform_analysis(&name, qmdl_store_lock.clone(), enable_dummy_analyzer)
.await
{
error!("failed to analyze {}: {}", name, err); error!("failed to analyze {}: {}", name, err);
} }
clear_running(analysis_status_lock.clone()).await; finish_running_analysis(analysis_status_lock.clone()).await;
} }
} }
Some(AnalysisCtrlMessage::RecordingFinished(name)) => {
let mut status = analysis_status_lock.write().await;
status.finished.push(name);
}
Some(AnalysisCtrlMessage::Exit) | None => return, Some(AnalysisCtrlMessage::Exit) | None => return,
} }
} }

View File

@@ -1,35 +1,61 @@
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
use rayhunter::{analysis::analyzer::Harness, diag::DataType, qmdl::QmdlReader};
use tokio::fs::{metadata, read_dir, File};
use clap::Parser; use clap::Parser;
use futures::TryStreamExt; use futures::TryStreamExt;
use log::{info, warn};
use rayhunter::{
analysis::analyzer::{EventType, Harness},
diag::DataType,
gsmtap_parser,
pcap::GsmtapPcapWriter,
qmdl::QmdlReader,
};
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
use tokio::fs::{metadata, read_dir, File};
mod dummy_analyzer; mod dummy_analyzer;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about)] #[command(version, about)]
struct Args { struct Args {
#[arg(short, long)] #[arg(short = 'p', long)]
qmdl_path: PathBuf, qmdl_path: PathBuf,
#[arg(short = 'c', long)]
pcapify: bool,
#[arg(long)] #[arg(long)]
show_skipped: bool, show_skipped: bool,
#[arg(long)] #[arg(long)]
enable_dummy_analyzer: bool, enable_dummy_analyzer: bool,
#[arg(short, long)]
verbose: bool,
} }
async fn analyze_file(harness: &mut Harness, qmdl_path: &str, show_skipped: bool) { async fn analyze_file(enable_dummy_analyzer: bool, qmdl_path: &str, show_skipped: bool) {
let mut harness = Harness::new_with_all_analyzers();
if enable_dummy_analyzer {
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
}
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file"); let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
let file_size = qmdl_file.metadata().await.expect("failed to get QMDL file metadata").len(); let file_size = qmdl_file
.metadata()
.await
.expect("failed to get QMDL file metadata")
.len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize)); let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
let mut qmdl_stream = pin!(qmdl_reader.as_stream() let mut qmdl_stream = pin!(qmdl_reader
.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace))); .try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
let mut skipped_reasons: HashMap<String, i32> = HashMap::new(); let mut skipped_reasons: HashMap<String, i32> = HashMap::new();
let mut total_messages = 0; let mut total_messages = 0;
let mut warnings = 0; let mut warnings = 0;
let mut skipped = 0; let mut skipped = 0;
while let Some(container) = qmdl_stream.try_next().await.expect("failed getting QMDL container") { while let Some(container) = qmdl_stream
.try_next()
.await
.expect("failed getting QMDL container")
{
let row = harness.analyze_qmdl_messages(container); let row = harness.analyze_qmdl_messages(container);
total_messages += 1; total_messages += 1;
for reason in row.skipped_message_reasons { for reason in row.skipped_message_reasons {
@@ -38,47 +64,113 @@ async fn analyze_file(harness: &mut Harness, qmdl_path: &str, show_skipped: bool
} }
for analysis in row.analysis { for analysis in row.analysis {
for maybe_event in analysis.events { for maybe_event in analysis.events {
if let Some(event) = maybe_event { let Some(event) = maybe_event else { continue };
warnings += 1; match event.event_type {
println!("{}: {:?}", analysis.timestamp, event); EventType::Informational => {
info!(
"{}: INFO - {} {}",
qmdl_path, analysis.timestamp, event.message,
);
}
EventType::QualitativeWarning { severity } => {
warn!(
"{}: WARNING (Severity: {:?}) - {} {}",
qmdl_path, severity, analysis.timestamp, event.message,
);
warnings += 1;
}
} }
} }
} }
} }
if show_skipped && skipped > 0 { if show_skipped && skipped > 0 {
println!("{}: messages skipped:", qmdl_path); info!("{}: messages skipped:", qmdl_path);
for (reason, count) in skipped_reasons.iter() { for (reason, count) in skipped_reasons.iter() {
println!(" - {}: \"{}\"", count, reason); info!(" - {}: \"{}\"", count, reason);
} }
} }
println!("{}: {} messages analyzed, {} warnings, {} messages skipped", qmdl_path, total_messages, warnings, skipped); info!(
"{}: {} messages analyzed, {} warnings, {} messages skipped",
qmdl_path, total_messages, warnings, skipped
);
}
async fn pcapify(qmdl_path: &PathBuf) {
let qmdl_file = &mut File::open(&qmdl_path)
.await
.expect("failed to open qmdl file");
let qmdl_file_size = qmdl_file.metadata().await.unwrap().len();
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(qmdl_file_size as usize));
let mut pcap_path = qmdl_path.clone();
pcap_path.set_extension("pcap");
let pcap_file = &mut File::create(&pcap_path)
.await
.expect("failed to open pcap file");
let mut pcap_writer = GsmtapPcapWriter::new(pcap_file).await.unwrap();
pcap_writer.write_iface_header().await.unwrap();
while let Some(container) = qmdl_reader
.get_next_messages_container()
.await
.expect("failed to get container")
{
for msg in container.into_messages().into_iter().flatten() {
if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
pcap_writer
.write_gsmtap_message(parsed, timestamp)
.await
.expect("failed to write");
}
}
}
info!("wrote pcap to {:?}", &pcap_path);
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
env_logger::init();
let args = Args::parse(); let args = Args::parse();
let level = if args.verbose {
log::LevelFilter::Trace
} else {
log::LevelFilter::Warn
};
simple_logger::SimpleLogger::new()
.with_colors(true)
.without_timestamps()
.with_level(level)
.init()
.unwrap();
info!("Analyzers:");
let mut harness = Harness::new_with_all_analyzers(); let mut harness = Harness::new_with_all_analyzers();
if args.enable_dummy_analyzer { if args.enable_dummy_analyzer {
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 })); harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
} }
println!("Analyzers:");
for analyzer in harness.get_metadata().analyzers { for analyzer in harness.get_metadata().analyzers {
println!(" - {}: {}", analyzer.name, analyzer.description); info!(" - {}: {}", analyzer.name, analyzer.description);
} }
let metadata = metadata(&args.qmdl_path).await.expect("failed to get metadata"); let metadata = metadata(&args.qmdl_path)
.await
.expect("failed to get metadata");
if metadata.is_dir() { if metadata.is_dir() {
let mut dir = read_dir(&args.qmdl_path).await.expect("failed to read dir"); let mut dir = read_dir(&args.qmdl_path).await.expect("failed to read dir");
while let Some(entry) = dir.next_entry().await.expect("failed to get entry") { while let Some(entry) = dir.next_entry().await.expect("failed to get entry") {
let name = entry.file_name(); let name = entry.file_name();
let name_str = name.to_str().unwrap(); let name_str = name.to_str().unwrap();
if name_str.ends_with(".qmdl") { if name_str.ends_with(".qmdl") {
analyze_file(&mut harness, entry.path().to_str().unwrap(), args.show_skipped).await; let path = entry.path();
let path_str = path.to_str().unwrap();
analyze_file(args.enable_dummy_analyzer, path_str, args.show_skipped).await;
if args.pcapify {
pcapify(&path).await;
}
} }
} }
} else { } else {
analyze_file(&mut harness, args.qmdl_path.to_str().unwrap(), args.show_skipped).await; let path = args.qmdl_path.to_str().unwrap();
analyze_file(args.enable_dummy_analyzer, path, args.show_skipped).await;
if args.pcapify {
pcapify(&args.qmdl_path).await;
}
} }
} }

View File

@@ -2,22 +2,16 @@ use crate::error::RayhunterError;
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize)] #[derive(Debug, Deserialize)]
struct ConfigFile { #[serde(default)]
qmdl_store_path: Option<String>,
port: Option<u16>,
debug_mode: Option<bool>,
ui_level: Option<u8>,
enable_dummy_analyzer: Option<bool>,
}
#[derive(Debug)]
pub struct Config { pub struct Config {
pub qmdl_store_path: String, pub qmdl_store_path: String,
pub port: u16, pub port: u16,
pub debug_mode: bool, pub debug_mode: bool,
pub ui_level: u8, pub ui_level: u8,
pub enable_dummy_analyzer: bool, pub enable_dummy_analyzer: bool,
pub colorblind_mode: bool,
pub key_input_mode: u8,
} }
impl Default for Config { impl Default for Config {
@@ -28,22 +22,21 @@ impl Default for Config {
debug_mode: false, debug_mode: false,
ui_level: 1, ui_level: 1,
enable_dummy_analyzer: false, enable_dummy_analyzer: false,
colorblind_mode: false,
key_input_mode: 1,
} }
} }
} }
pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError> where P: AsRef<std::path::Path> { pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
let mut config = Config::default(); where
P: AsRef<std::path::Path>,
{
if let Ok(config_file) = std::fs::read_to_string(&path) { if let Ok(config_file) = std::fs::read_to_string(&path) {
let parsed_config: ConfigFile = toml::from_str(&config_file) Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
.map_err(RayhunterError::ConfigFileParsingError)?; } else {
parsed_config.qmdl_store_path.map(|v| config.qmdl_store_path = v); Ok(Config::default())
parsed_config.port.map(|v| config.port = v);
parsed_config.debug_mode.map(|v| config.debug_mode = v);
parsed_config.ui_level.map(|v| config.ui_level = v);
parsed_config.enable_dummy_analyzer.map(|v| config.enable_dummy_analyzer = v);
} }
Ok(config)
} }
pub struct Args { pub struct Args {

View File

@@ -1,42 +1,63 @@
mod analysis; mod analysis;
mod config; mod config;
mod diag;
mod display;
mod dummy_analyzer;
mod error; mod error;
mod key_input;
mod pcap; mod pcap;
mod qmdl_store;
mod server; mod server;
mod stats; mod stats;
mod qmdl_store;
mod diag;
mod framebuffer;
mod dummy_analyzer;
use crate::config::{parse_config, parse_args}; use crate::config::{parse_args, parse_config};
use crate::diag::run_diag_read_thread; use crate::diag::run_diag_read_thread;
use crate::qmdl_store::RecordingStore;
use crate::server::{ServerState, get_qmdl, serve_static};
use crate::pcap::get_pcap;
use crate::stats::get_system_stats;
use crate::error::RayhunterError; use crate::error::RayhunterError;
use crate::framebuffer::Framebuffer; use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore;
use crate::server::{get_qmdl, serve_static, ServerState};
use crate::stats::get_system_stats;
use analysis::{get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus}; use analysis::{
get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus,
};
use axum::response::Redirect; use axum::response::Redirect;
use diag::{get_analysis_report, start_recording, stop_recording, DiagDeviceCtrlMessage};
use log::{info, error};
use rayhunter::diag_device::DiagDevice;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::Router; use axum::Router;
use diag::{
delete_all_recordings, delete_recording, get_analysis_report, start_recording, stop_recording,
DiagDeviceCtrlMessage,
};
use log::{error, info};
use qmdl_store::RecordingStoreError;
use rayhunter::diag_device::DiagDevice;
use stats::get_qmdl_manifest; use stats::get_qmdl_manifest;
use tokio::sync::mpsc::{self, Sender, Receiver}; use std::net::SocketAddr;
use tokio::sync::oneshot::error::TryRecvError; use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::mpsc::{self, Sender};
use tokio::sync::{oneshot, RwLock};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use std::net::SocketAddr;
use std::thread::sleep; type AppRouter = Router<Arc<ServerState>>;
use std::time::Duration;
use tokio::net::TcpListener; fn get_router() -> AppRouter {
use tokio::sync::{RwLock, oneshot}; Router::new()
use std::sync::Arc; .route("/api/pcap/{name}", get(get_pcap))
use include_dir::{include_dir, Dir}; .route("/api/qmdl/{name}", get(get_qmdl))
.route("/api/system-stats", get(get_system_stats))
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
.route("/api/start-recording", post(start_recording))
.route("/api/stop-recording", post(stop_recording))
.route("/api/delete-recording/{name}", post(delete_recording))
.route("/api/delete-all-recordings", post(delete_all_recordings))
.route("/api/analysis-report/{name}", get(get_analysis_report))
.route("/api/analysis", get(get_analysis_status))
.route("/api/analysis/{name}", post(start_analysis))
.route("/", get(|| async { Redirect::permanent("/index.html") }))
.route("/{*path}", get(serve_static))
}
// Runs the axum server, taking all the elements needed to build up our // Runs the axum server, taking all the elements needed to build up our
// ServerState and a oneshot Receiver that'll fire when it's time to shutdown // ServerState and a oneshot Receiver that'll fire when it's time to shutdown
@@ -44,43 +65,19 @@ use include_dir::{include_dir, Dir};
async fn run_server( async fn run_server(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
config: &config::Config, config: &config::Config,
qmdl_store_lock: Arc<RwLock<RecordingStore>>, state: Arc<ServerState>,
server_shutdown_rx: oneshot::Receiver<()>, server_shutdown_rx: oneshot::Receiver<()>,
ui_update_tx: Sender<framebuffer::DisplayState>,
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
) -> JoinHandle<()> { ) -> JoinHandle<()> {
info!("spinning up server"); info!("spinning up server");
let state = Arc::new(ServerState { let app = get_router().with_state(state);
qmdl_store_lock,
diag_device_ctrl_sender: diag_device_sender,
ui_update_sender: ui_update_tx,
debug_mode: config.debug_mode,
analysis_status_lock,
analysis_sender,
});
let app = Router::new()
.route("/api/pcap/*name", get(get_pcap))
.route("/api/qmdl/*name", get(get_qmdl))
.route("/api/system-stats", get(get_system_stats))
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
.route("/api/start-recording", post(start_recording))
.route("/api/stop-recording", post(stop_recording))
.route("/api/analysis-report/*name", get(get_analysis_report))
.route("/api/analysis", get(get_analysis_status))
.route("/api/analysis/*name", post(start_analysis))
.route("/", get(|| async { Redirect::permanent("/index.html") }))
.route("/*path", get(serve_static))
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
let listener = TcpListener::bind(&addr).await.unwrap(); let listener = TcpListener::bind(&addr).await.unwrap();
task_tracker.spawn(async move { task_tracker.spawn(async move {
info!("The orca is hunting for stingrays..."); info!("The orca is hunting for stingrays...");
axum::serve(listener, app) axum::serve(listener, app)
.with_graceful_shutdown(server_shutdown_signal(server_shutdown_rx)) .with_graceful_shutdown(server_shutdown_signal(server_shutdown_rx))
.await.unwrap(); .await
.unwrap();
}) })
} }
@@ -89,13 +86,31 @@ async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
info!("Server received shutdown signal, exiting..."); info!("Server received shutdown signal, exiting...");
} }
// Loads a QmdlStore if one exists, and if not, only create one if we're not in // Loads a RecordingStore if one exists, and if not, only create one if we're
// debug mode. // not in debug mode. If we fail to parse the manifest AND we're not in debug
// mode, try to recover by making a new (empty) manifest in the same directory.
async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> { async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
match (RecordingStore::exists(&config.qmdl_store_path).await?, config.debug_mode) { let store_exists = RecordingStore::exists(&config.qmdl_store_path).await?;
(true, _) => Ok(RecordingStore::load(&config.qmdl_store_path).await?), if config.debug_mode {
(false, false) => Ok(RecordingStore::create(&config.qmdl_store_path).await?), if store_exists {
(false, true) => Err(RayhunterError::NoStoreDebugMode(config.qmdl_store_path.clone())), Ok(RecordingStore::load(&config.qmdl_store_path).await?)
} else {
Err(RayhunterError::NoStoreDebugMode(
config.qmdl_store_path.clone(),
))
}
} else if store_exists {
match RecordingStore::load(&config.qmdl_store_path).await {
Ok(store) => Ok(store),
Err(RecordingStoreError::ParseManifestError(err)) => {
error!("failed to parse QMDL manifest: {}", err);
info!("creating new empty manifest...");
Ok(RecordingStore::create(&config.qmdl_store_path).await?)
}
Err(err) => Err(err.into()),
}
} else {
Ok(RecordingStore::create(&config.qmdl_store_path).await?)
} }
} }
@@ -120,18 +135,24 @@ fn run_ctrl_c_thread(
info!("Done!"); info!("Done!");
} }
server_shutdown_tx.send(()) server_shutdown_tx
.send(())
.expect("couldn't send server shutdown signal"); .expect("couldn't send server shutdown signal");
info!("sending UI shutdown"); info!("sending UI shutdown");
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx { if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
ui_shutdown_tx.send(()) ui_shutdown_tx
.send(())
.expect("couldn't send ui shutdown signal"); .expect("couldn't send ui shutdown signal");
} }
diag_device_sender.send(DiagDeviceCtrlMessage::Exit).await diag_device_sender
.send(DiagDeviceCtrlMessage::Exit)
.await
.expect("couldn't send Exit message to diag thread"); .expect("couldn't send Exit message to diag thread");
analysis_tx.send(AnalysisCtrlMessage::Exit).await analysis_tx
.send(AnalysisCtrlMessage::Exit)
.await
.expect("couldn't send Exit message to analysis thread"); .expect("couldn't send Exit message to analysis thread");
}, }
Err(err) => { Err(err) => {
error!("Unable to listen for shutdown signal: {}", err); error!("Unable to listen for shutdown signal: {}", err);
} }
@@ -140,64 +161,6 @@ fn run_ctrl_c_thread(
}) })
} }
fn update_ui(task_tracker: &TaskTracker, config: &config::Config, mut ui_shutdown_rx: oneshot::Receiver<()>, mut ui_update_rx: Receiver<framebuffer::DisplayState>) -> JoinHandle<()> {
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/images/");
let display_level = config.ui_level;
if display_level == 0 {
info!("Invisible mode, not spawning UI.");
}
let mut display_color = framebuffer::Color565::Green;
task_tracker.spawn_blocking(move || {
let mut fb: Framebuffer = Framebuffer::new();
// this feels wrong, is there a more rusty way to do this?
let mut img: Option<&[u8]> = None;
if display_level == 2 {
img = Some(IMAGE_DIR.get_file("orca.gif").expect("failed to read orca.gif").contents());
} else if display_level == 3 {
img = Some(IMAGE_DIR.get_file("eff.png").expect("failed to read eff.png").contents());
}
loop {
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
},
Err(TryRecvError::Empty) => {},
Err(e) => panic!("error receiving shutdown message: {e}")
}
match ui_update_rx.try_recv() {
Ok(state) => {
display_color = state.into();
},
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {},
Err(e) => error!("error receiving framebuffer update message: {e}")
}
match display_level {
2 => {
fb.draw_gif(img.unwrap());
},
3 => {
fb.draw_img(img.unwrap())
},
128 => {
fb.draw_line(framebuffer::Color565::Cyan, 128);
fb.draw_line(framebuffer::Color565::Pink, 102);
fb.draw_line(framebuffer::Color565::White, 76);
fb.draw_line(framebuffer::Color565::Pink, 50);
fb.draw_line(framebuffer::Color565::Cyan, 25);
},
1 | _ => {
fb.draw_line(display_color, 2);
},
};
sleep(Duration::from_millis(1000));
}
})
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), RayhunterError> { async fn main() -> Result<(), RayhunterError> {
env_logger::init(); env_logger::init();
@@ -210,30 +173,66 @@ async fn main() -> Result<(), RayhunterError> {
let task_tracker = TaskTracker::new(); let task_tracker = TaskTracker::new();
println!("R A Y H U N T E R 🐳"); println!("R A Y H U N T E R 🐳");
let qmdl_store_lock = Arc::new(RwLock::new(init_qmdl_store(&config).await?)); let store = init_qmdl_store(&config).await?;
let (tx, rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1); let analysis_status = AnalysisStatus::new(&store);
let (ui_update_tx, ui_update_rx) = mpsc::channel::<framebuffer::DisplayState>(1); let qmdl_store_lock = Arc::new(RwLock::new(store));
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 (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
let mut maybe_ui_shutdown_tx = None; let mut maybe_ui_shutdown_tx = None;
if !config.debug_mode { if !config.debug_mode {
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel(); let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
maybe_ui_shutdown_tx = Some(ui_shutdown_tx); maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
let mut dev = DiagDevice::new().await let mut dev = DiagDevice::new()
.await
.map_err(RayhunterError::DiagInitError)?; .map_err(RayhunterError::DiagInitError)?;
dev.config_logs().await dev.config_logs()
.await
.map_err(RayhunterError::DiagInitError)?; .map_err(RayhunterError::DiagInitError)?;
info!("Starting Diag Thread"); info!("Starting Diag Thread");
run_diag_read_thread(&task_tracker, dev, rx, ui_update_tx.clone(), qmdl_store_lock.clone(), config.enable_dummy_analyzer); run_diag_read_thread(
&task_tracker,
dev,
diag_rx,
ui_update_tx.clone(),
qmdl_store_lock.clone(),
analysis_tx.clone(),
config.enable_dummy_analyzer,
);
info!("Starting UI"); info!("Starting UI");
update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx); display::update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
info!("Starting Key Input service");
key_input::run_key_input_thread(&task_tracker, &config, diag_tx.clone());
} }
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>(); let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
info!("create shutdown thread"); info!("create shutdown thread");
let analysis_status_lock = Arc::new(RwLock::new(AnalysisStatus::default())); let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
run_analysis_thread(&task_tracker, analysis_rx, qmdl_store_lock.clone(), analysis_status_lock.clone(), config.enable_dummy_analyzer); run_analysis_thread(
run_ctrl_c_thread(&task_tracker, tx.clone(), server_shutdown_tx, maybe_ui_shutdown_tx, qmdl_store_lock.clone(), analysis_tx.clone()); &task_tracker,
run_server(&task_tracker, &config, qmdl_store_lock.clone(), server_shutdown_rx, ui_update_tx, tx, analysis_tx, analysis_status_lock).await; analysis_rx,
qmdl_store_lock.clone(),
analysis_status_lock.clone(),
config.enable_dummy_analyzer,
);
run_ctrl_c_thread(
&task_tracker,
diag_tx.clone(),
server_shutdown_tx,
maybe_ui_shutdown_tx,
qmdl_store_lock.clone(),
analysis_tx.clone(),
);
let state = Arc::new(ServerState {
qmdl_store_lock: qmdl_store_lock.clone(),
diag_device_ctrl_sender: diag_tx,
ui_update_sender: ui_update_tx,
debug_mode: config.debug_mode,
analysis_status_lock,
analysis_sender: analysis_tx,
});
run_server(&task_tracker, &config, state, server_shutdown_rx).await;
task_tracker.close(); task_tracker.close();
task_tracker.wait().await; task_tracker.wait().await;
@@ -241,3 +240,14 @@ async fn main() -> Result<(), RayhunterError> {
info!("see you space cowboy..."); info!("see you space cowboy...");
Ok(()) Ok(())
} }
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_get_router() {
// assert that creating the router does not panic from invalid route patterns.
let _ = get_router();
}
}

View File

@@ -6,25 +6,25 @@ use axum::extract::{Path, State};
use axum::http::header::CONTENT_TYPE; use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt};
use log::{debug, error, info, warn};
use rayhunter::diag::DataType; use rayhunter::diag::DataType;
use rayhunter::diag_device::DiagDevice; use rayhunter::diag_device::DiagDevice;
use tokio::sync::RwLock;
use tokio::sync::mpsc::{Receiver, Sender};
use rayhunter::qmdl::QmdlWriter; use rayhunter::qmdl::QmdlWriter;
use log::{debug, error, info};
use tokio::fs::File; use tokio::fs::File;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::RwLock;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use futures::{StreamExt, TryStreamExt};
use crate::framebuffer; use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
use crate::qmdl_store::RecordingStore; use crate::display;
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::server::ServerState; use crate::server::ServerState;
use crate::analysis::AnalysisWriter;
pub enum DiagDeviceCtrlMessage { pub enum DiagDeviceCtrlMessage {
StopRecording, StopRecording,
StartRecording((QmdlWriter<File>, File)), StartRecording,
Exit, Exit,
} }
@@ -32,8 +32,9 @@ pub fn run_diag_read_thread(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
mut dev: DiagDevice, mut dev: DiagDevice,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>, mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
ui_update_sender: Sender<framebuffer::DisplayState>, ui_update_sender: Sender<display::DisplayState>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>, qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_sender: Sender<AnalysisCtrlMessage>,
enable_dummy_analyzer: bool, enable_dummy_analyzer: bool,
) { ) {
task_tracker.spawn(async move { task_tracker.spawn(async move {
@@ -46,20 +47,56 @@ pub fn run_diag_read_thread(
tokio::select! { tokio::select! {
msg = qmdl_file_rx.recv() => { msg = qmdl_file_rx.recv() => {
match msg { match msg {
Some(DiagDeviceCtrlMessage::StartRecording((new_writer, new_analysis_file))) => { Some(DiagDeviceCtrlMessage::StartRecording) => {
maybe_qmdl_writer = Some(new_writer); let mut qmdl_store = qmdl_store_lock.write().await;
let (qmdl_file, new_analysis_file) = match qmdl_store.new_entry().await {
Ok(x) => x,
Err(e) => {
error!("couldn't create new qmdl entry: {}", e);
continue;
}
};
maybe_qmdl_writer = Some(QmdlWriter::new(qmdl_file));
if let Some(analysis_writer) = maybe_analysis_writer { if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer"); analysis_writer.close().await.expect("failed to close analysis writer");
} }
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer).await maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer).await
.expect("failed to write to analysis file")); .expect("failed to write to analysis file"));
if let Err(e) = ui_update_sender.send(display::DisplayState::Recording).await {
warn!("couldn't send ui update message: {}", e);
}
}, },
Some(DiagDeviceCtrlMessage::StopRecording) => { Some(DiagDeviceCtrlMessage::StopRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
match qmdl_store.get_current_entry() {
Some((_, entry)) => {
if let Err(e) = analysis_sender
.send(AnalysisCtrlMessage::RecordingFinished(
entry.name.to_string(),
))
.await {
warn!("couldn't send analysis message: {}", e);
}
}
None => todo!(),
}
if let Err(e) = qmdl_store.close_current_entry().await {
error!("couldn't close current entry: {}", e);
}
maybe_qmdl_writer = None; maybe_qmdl_writer = None;
if let Some(analysis_writer) = maybe_analysis_writer { if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer"); analysis_writer.close().await.expect("failed to close analysis writer");
} }
maybe_analysis_writer = None; maybe_analysis_writer = None;
if let Err(e) = ui_update_sender.send(display::DisplayState::Paused).await {
warn!("couldn't send ui update message: {}", e);
}
}, },
// None means all the Senders have been dropped, so it's // None means all the Senders have been dropped, so it's
// time to go // time to go
@@ -99,12 +136,12 @@ pub fn run_diag_read_thread(
let (analysis_file_len, heuristic_warning) = analysis_output; let (analysis_file_len, heuristic_warning) = analysis_output;
if heuristic_warning { if heuristic_warning {
info!("a heuristic triggered on this run!"); info!("a heuristic triggered on this run!");
ui_update_sender.send(framebuffer::DisplayState::WarningDetected).await ui_update_sender.send(display::DisplayState::WarningDetected).await
.expect("couldn't send ui update message: {}"); .expect("couldn't send ui update message: {}");
} }
let mut qmdl_store = qmdl_store_lock.write().await; let mut qmdl_store = qmdl_store_lock.write().await;
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???"); let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
qmdl_store.update_entry_analysis_size(index, analysis_file_len as usize).await qmdl_store.update_entry_analysis_size(index, analysis_file_len).await
.expect("failed to update analysis file size"); .expect("failed to update analysis file size");
} }
}, },
@@ -119,49 +156,147 @@ pub fn run_diag_read_thread(
}); });
} }
pub async fn start_recording(State(state): State<Arc<ServerState>>) -> Result<(StatusCode, String), (StatusCode, String)> { pub async fn start_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StartRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send start recording message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
pub async fn stop_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
pub async fn delete_recording(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode { if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string())); return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
} }
let mut qmdl_store = state.qmdl_store_lock.write().await; let mut qmdl_store = state.qmdl_store_lock.write().await;
let (qmdl_file, analysis_file) = qmdl_store.new_entry().await match qmdl_store.delete_entry(&qmdl_name).await {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't create new qmdl entry: {}", e)))?; Err(RecordingStoreError::NoSuchEntryError) => {
let qmdl_writer = QmdlWriter::new(qmdl_file); return Err((
state.diag_device_ctrl_sender.send(DiagDeviceCtrlMessage::StartRecording((qmdl_writer, analysis_file))).await StatusCode::BAD_REQUEST,
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send stop recording message: {}", e)))?; format!("no recording with name {qmdl_name}"),
state.ui_update_sender.send(framebuffer::DisplayState::Recording).await ))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send ui update message: {}", e)))?; }
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recording: {e}"),
))
}
Ok(_) => {}
}
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {}", e),
)
})?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string())) Ok((StatusCode::ACCEPTED, "ok".to_string()))
} }
pub async fn stop_recording(State(state): State<Arc<ServerState>>) -> Result<(StatusCode, String), (StatusCode, String)> { pub async fn delete_all_recordings(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode { if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string())); return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
} }
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {}", e),
)
})?;
let mut qmdl_store = state.qmdl_store_lock.write().await; let mut qmdl_store = state.qmdl_store_lock.write().await;
qmdl_store.close_current_entry().await qmdl_store.delete_all_entries().await.map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't close current qmdl entry: {}", e)))?; (
state.diag_device_ctrl_sender.send(DiagDeviceCtrlMessage::StopRecording).await StatusCode::INTERNAL_SERVER_ERROR,
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send stop recording message: {}", e)))?; format!("couldn't delete all recordings: {}", e),
state.ui_update_sender.send(framebuffer::DisplayState::Paused).await )
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send ui update message: {}", e)))?; })?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string())) Ok((StatusCode::ACCEPTED, "ok".to_string()))
} }
pub async fn get_analysis_report(State(state): State<Arc<ServerState>>, Path(qmdl_name): Path<String>) -> Result<Response, (StatusCode, String)> { pub async fn get_analysis_report(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await; let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, _) = if qmdl_name == "live" { let (entry_index, _) = if qmdl_name == "live" {
qmdl_store.get_current_entry().ok_or(( qmdl_store.get_current_entry().ok_or((
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string() "No QMDL data's being recorded to analyze, try starting a new recording!".to_string(),
))? ))?
} else { } else {
qmdl_store.entry_for_name(&qmdl_name).ok_or(( qmdl_store.entry_for_name(&qmdl_name).ok_or((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
format!("Couldn't find QMDL entry with name \"{}\"", qmdl_name) format!("Couldn't find QMDL entry with name \"{}\"", qmdl_name),
))? ))?
}; };
let analysis_file = qmdl_store.open_entry_analysis(entry_index).await let analysis_file = qmdl_store
.open_entry_analysis(entry_index)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
let analysis_stream = ReaderStream::new(analysis_file); let analysis_stream = ReaderStream::new(analysis_file);

View File

@@ -0,0 +1,202 @@
use image::{codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage};
use std::io::Cursor;
use std::time::Duration;
use crate::config;
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::task::TaskTracker;
use std::thread::sleep;
use include_dir::{include_dir, Dir};
#[derive(Copy, Clone)]
pub struct Dimensions {
pub height: u32,
pub width: u32,
}
#[allow(dead_code)]
#[derive(Copy, Clone)]
pub enum Color {
Red,
Green,
Blue,
White,
Black,
Cyan,
Yellow,
Pink,
}
impl Color {
fn rgb(self) -> (u8, u8, u8) {
match self {
Color::Red => (0xff, 0, 0),
Color::Green => (0, 0xff, 0),
Color::Blue => (0, 0, 0xff),
Color::White => (0xff, 0xff, 0xff),
Color::Black => (0, 0, 0),
Color::Cyan => (0, 0xff, 0xff),
Color::Yellow => (0xff, 0xff, 0),
Color::Pink => (0xfe, 0x24, 0xff),
}
}
}
impl Color {
fn from_state(state: DisplayState, colorblind_mode: bool) -> Self {
match state {
DisplayState::Paused => Color::White,
DisplayState::Recording => {
if colorblind_mode {
Color::Blue
} else {
Color::Green
}
}
DisplayState::WarningDetected => Color::Red,
}
}
}
pub trait GenericFramebuffer: Send + 'static {
fn dimensions(&self) -> Dimensions;
fn write_buffer(
&mut self,
buffer: &[(u8, u8, u8)], // rgb, row-wise, left-to-right, top-to-bottom
);
fn write_dynamic_image(&mut self, img: DynamicImage) {
let dimensions = self.dimensions();
let mut width = img.width();
let mut height = img.height();
let resized_img: DynamicImage;
if height > dimensions.height || width > dimensions.width {
resized_img = img.resize(dimensions.width, dimensions.height, FilterType::CatmullRom);
width = dimensions.width.min(resized_img.width());
height = dimensions.height.min(resized_img.height());
} else {
resized_img = img;
}
let img_rgba8 = resized_img.as_rgba8().unwrap();
let mut buf = Vec::new();
for y in 0..height {
for x in 0..width {
let px = img_rgba8.get_pixel(x, y);
buf.push((px[0], px[1], px[2]));
}
}
self.write_buffer(&buf);
}
fn draw_gif(&mut self, img_buffer: &[u8]) {
// this is dumb and i'm sure there's a better way to loop this
let cursor = Cursor::new(img_buffer);
let decoder = GifDecoder::new(cursor).unwrap();
for maybe_frame in decoder.into_frames() {
let frame = maybe_frame.unwrap();
let (numerator, _) = frame.delay().numer_denom_ms();
let img = DynamicImage::from(frame.into_buffer());
self.write_dynamic_image(img);
std::thread::sleep(Duration::from_millis(numerator as u64));
}
}
fn draw_img(&mut self, img_buffer: &[u8]) {
let img = image::load_from_memory(img_buffer).unwrap();
self.write_dynamic_image(img);
}
fn draw_line(&mut self, color: Color, height: u32) {
let width = self.dimensions().width;
let px_num = height * width;
let mut buffer = Vec::new();
for _ in 0..px_num {
buffer.push(color.rgb());
}
self.write_buffer(&buffer);
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
mut fb: impl GenericFramebuffer,
mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: Receiver<DisplayState>,
) {
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
let display_level = config.ui_level;
if display_level == 0 {
info!("Invisible mode, not spawning UI.");
}
let colorblind_mode = config.colorblind_mode;
let mut display_color = Color::from_state(DisplayState::Recording, colorblind_mode);
task_tracker.spawn_blocking(move || {
// this feels wrong, is there a more rusty way to do this?
let mut img: Option<&[u8]> = None;
if display_level == 2 {
img = Some(
IMAGE_DIR
.get_file("orca.gif")
.expect("failed to read orca.gif")
.contents(),
);
} else if display_level == 3 {
img = Some(
IMAGE_DIR
.get_file("eff.png")
.expect("failed to read eff.png")
.contents(),
);
}
loop {
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
}
Err(TryRecvError::Empty) => {}
Err(e) => panic!("error receiving shutdown message: {e}"),
}
match ui_update_rx.try_recv() {
Ok(state) => {
display_color = Color::from_state(state, colorblind_mode);
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(e) => error!("error receiving framebuffer update message: {e}"),
}
match display_level {
2 => {
fb.draw_gif(img.unwrap());
}
3 => fb.draw_img(img.unwrap()),
128 => {
fb.draw_line(Color::Cyan, 128);
fb.draw_line(Color::Pink, 102);
fb.draw_line(Color::White, 76);
fb.draw_line(Color::Pink, 50);
fb.draw_line(Color::Cyan, 25);
}
_ => {
// this branch id for ui_level 1, which is also the default if an
// unknown value is used
fb.draw_line(display_color, 2);
}
};
sleep(Duration::from_millis(1000));
}
});
}

28
bin/src/display/mod.rs Normal file
View File

@@ -0,0 +1,28 @@
mod generic_framebuffer;
#[cfg(feature = "tplink")]
mod tplink;
#[cfg(feature = "tplink")]
mod tplink_framebuffer;
#[cfg(feature = "tplink")]
mod tplink_onebit;
#[cfg(feature = "tplink")]
pub use tplink::update_ui;
#[cfg(feature = "orbic")]
mod orbic;
#[cfg(feature = "orbic")]
pub use orbic::update_ui;
pub enum DisplayState {
Recording,
Paused,
WarningDetected,
}
#[cfg(all(feature = "orbic", feature = "tplink"))]
compile_error!("cannot compile for many devices at once");
#[cfg(not(any(feature = "orbic", feature = "tplink")))]
compile_error!("cannot compile for no device at all");

49
bin/src/display/orbic.rs Normal file
View File

@@ -0,0 +1,49 @@
use crate::config;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use crate::display::DisplayState;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)]
struct Framebuffer;
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
Dimensions {
height: 128,
width: 128,
}
}
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
let mut raw_buffer = Vec::new();
for (r, g, b) in buffer {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3;
raw_buffer.extend(rgb565.to_le_bytes());
}
std::fs::write(FB_PATH, &raw_buffer).unwrap();
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
ui_shutdown_rx: oneshot::Receiver<()>,
ui_update_rx: Receiver<DisplayState>,
) {
generic_framebuffer::update_ui(
task_tracker,
config,
Framebuffer,
ui_shutdown_rx,
ui_update_rx,
)
}

29
bin/src/display/tplink.rs Normal file
View File

@@ -0,0 +1,29 @@
use log::info;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::display::{tplink_framebuffer, tplink_onebit, DisplayState};
use std::fs;
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
ui_shutdown_rx: oneshot::Receiver<()>,
ui_update_rx: Receiver<DisplayState>,
) {
let display_level = config.ui_level;
if display_level == 0 {
info!("Invisible mode, not spawning UI.");
}
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)
} else {
info!("fallback to framebuffer");
tplink_framebuffer::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
}
}

View File

@@ -0,0 +1,90 @@
use std::fs::File;
use std::io::Write;
use std::os::fd::AsRawFd;
use crate::config;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use crate::display::DisplayState;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0";
struct Framebuffer;
#[repr(C)]
struct fb_fillrect {
dx: u32,
dy: u32,
width: u32,
height: u32,
color: u32,
rop: u32,
}
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
Dimensions {
height: 128,
width: 128,
}
}
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
let dimensions = self.dimensions();
let width = dimensions.width;
let height = buffer.len() as u32 / width;
let mut f = File::options().write(true).open(FB_PATH).unwrap();
let mut arg = fb_fillrect {
dx: 0,
dy: 0,
width,
height,
color: 0xffff, // not sure what this is
rop: 0,
};
let mut raw_buffer = Vec::new();
for (r, g, b) in buffer {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3;
// note: big-endian!
raw_buffer.extend(rgb565.to_be_bytes());
}
f.write_all(&raw_buffer).unwrap();
unsafe {
let res = libc::ioctl(
f.as_raw_fd(),
0x4619, // FBIORECT_DISPLAY
&mut arg as *mut _,
std::mem::size_of::<fb_fillrect>(),
);
if res < 0 {
panic!("failed to send FBIORECT_DISPLAY ioctl, {}", res);
}
}
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
ui_shutdown_rx: oneshot::Receiver<()>,
ui_update_rx: Receiver<DisplayState>,
) {
generic_framebuffer::update_ui(
task_tracker,
config,
Framebuffer,
ui_shutdown_rx,
ui_update_rx,
)
}

View File

@@ -0,0 +1,170 @@
/// Display module for the TP-Link M7350 oled one-bit display.
///
/// https://github.com/m0veax/tplink_m7350/tree/main/oled
use crate::config;
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::task::TaskTracker;
use std::fs;
use std::thread::sleep;
use std::time::Duration;
pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer";
// those coordinates were mainly chosen for a spot that doesn't get regularly updated by the main
// oledd service. otherwise we'd have to write to the display more than once per second to prevent
// the icon from flickering.
const STATUS_X: u8 = 104;
const STATUS_Y: u8 = 40;
const STATUS_W: u8 = 16;
const STATUS_H: u8 = 16;
macro_rules! pixel {
(x) => {
0
};
(_) => {
1
};
}
macro_rules! pixelart {
(x=$x:expr, y=$y:expr, width=$width:expr, height=$height:expr; $($a:tt $b:tt $c:tt $d:tt $e:tt $f:tt $g:tt $h:tt)*) => {{
// one bit per pixel + 4 bytes for header
const BUF_SIZE: usize = ($width as usize * $height as usize) / 8 + 4;
const BUF_BYTES: [u8; BUF_SIZE] = [
$x,
$y,
$width,
$height,
$(
(pixel!($a) << 7 | pixel!($b) << 6 | pixel!($c) << 5 | pixel!($d) << 4 | pixel!($e) << 3 | pixel!($f) << 2 | pixel!($g) << 1 | pixel!($h)),
)*
];
&BUF_BYTES
}}
}
const STATUS_PAUSED: &[u8] = pixelart! {
x=STATUS_X, y=STATUS_Y, width=STATUS_W, height=STATUS_H;
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
_ _ _ x x x x x x x x x x _ _ _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ x _ _ _ _ x _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ _ _ x x x x x x x x x x _ _ _
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
};
const STATUS_SMILING: &[u8] = pixelart! {
x=STATUS_X, y=STATUS_Y, width=STATUS_W, height=STATUS_H;
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
_ _ _ x x x x x x x x x x _ _ _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ x _ _ _ _ x _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ x _ _ _ _ x _ _ _ x _
_ x _ _ _ x _ _ _ _ x _ _ _ x _
_ x _ _ _ x x x x x x _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ _ _ x x x x x x x x x x _ _ _
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
};
const STATUS_WARNING: &[u8] = pixelart! {
x=STATUS_X, y=STATUS_Y, width=STATUS_W, height=STATUS_H;
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
_ _ _ x x x x x x x x x x _ _ _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ x x _ _ _ _ _ x _
_ x _ _ _ _ _ x x _ _ _ _ _ x _
_ x _ _ _ _ _ x x _ _ _ _ _ x _
_ x _ _ _ _ _ x x _ _ _ _ _ x _
_ x _ _ _ _ _ x x _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x _ _ _ _ _ x x _ _ _ _ _ x _
_ x _ _ _ _ _ x x _ _ _ _ _ x _
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
_ x x _ _ _ _ _ _ _ _ _ _ x x _
_ _ _ x x x x x x x x x x _ _ _
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
};
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: Receiver<DisplayState>,
) {
let display_level = config.ui_level;
if display_level == 0 {
info!("Invisible mode, not spawning UI.");
}
task_tracker.spawn_blocking(move || {
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}"),
}
match ui_update_rx.try_recv() {
Ok(DisplayState::Paused) => pixels = STATUS_PAUSED,
Ok(DisplayState::Recording) => pixels = STATUS_SMILING,
Ok(DisplayState::WarningDetected) => pixels = STATUS_WARNING,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(e) => {
error!("error receiving framebuffer update message: {e}");
}
};
// we write the status every second because it may have been overwritten through menu
// navigation.
if display_level != 0 {
if let Err(e) = fs::write(OLED_PATH, &pixels) {
error!("failed to write to display: {e}");
}
}
sleep(Duration::from_millis(1000));
}
});
}
#[test]
fn test_pixelart_macro() {
assert_eq!(
STATUS_WARNING,
[
104, 40, 16, 16, 255, 255, 224, 7, 159, 249, 191, 253, 190, 125, 190, 125, 190, 125,
190, 125, 190, 125, 191, 253, 190, 125, 190, 125, 191, 253, 159, 249, 224, 7, 255, 255
]
);
}

View File

@@ -5,11 +5,11 @@ use rayhunter::telcom_parser::lte_rrc::{PCCH_MessageType, PCCH_MessageType_c1, P
use rayhunter::analysis::analyzer::{Analyzer, Event, EventType, Severity}; use rayhunter::analysis::analyzer::{Analyzer, Event, EventType, Severity};
use rayhunter::analysis::information_element::{InformationElement, LteInformationElement}; use rayhunter::analysis::information_element::{InformationElement, LteInformationElement};
pub struct TestAnalyzer{ pub struct TestAnalyzer {
pub count: i32, pub count: i32,
} }
impl Analyzer for TestAnalyzer{ impl Analyzer for TestAnalyzer {
fn get_name(&self) -> Cow<str> { fn get_name(&self) -> Cow<str> {
Cow::from("Example Analyzer") Cow::from("Example Analyzer")
} }
@@ -22,12 +22,16 @@ impl Analyzer for TestAnalyzer{
self.count += 1; self.count += 1;
if self.count % 100 == 0 { if self.count % 100 == 0 {
return Some(Event { return Some(Event {
event_type: EventType::Informational , event_type: EventType::Informational,
message: "multiple of 100 events processed".to_string(), message: "multiple of 100 events processed".to_string(),
}) });
} }
let InformationElement::LTE(LteInformationElement::PCCH(pcch_msg)) = ie else { let pcch_msg = match ie {
return None; InformationElement::LTE(lte_ie) => match &**lte_ie {
LteInformationElement::PCCH(pcch_msg) => pcch_msg,
_ => return None,
},
_ => return None,
}; };
let PCCH_MessageType::C1(PCCH_MessageType_c1::Paging(paging)) = &pcch_msg.message else { let PCCH_MessageType::C1(PCCH_MessageType_c1::Paging(paging)) = &pcch_msg.message else {
return None; return None;
@@ -35,9 +39,11 @@ impl Analyzer for TestAnalyzer{
for record in &paging.paging_record_list.as_ref()?.0 { for record in &paging.paging_record_list.as_ref()?.0 {
if let PagingUE_Identity::S_TMSI(_) = record.ue_identity { if let PagingUE_Identity::S_TMSI(_) = record.ue_identity {
return Some(Event { return Some(Event {
event_type: EventType::QualitativeWarning { severity: Severity::Low }, event_type: EventType::QualitativeWarning {
severity: Severity::Low,
},
message: "TMSI was provided to cell".to_string(), message: "TMSI was provided to cell".to_string(),
}) });
} }
} }
None None

View File

@@ -1,10 +1,10 @@
use thiserror::Error;
use rayhunter::diag_device::DiagDeviceError; use rayhunter::diag_device::DiagDeviceError;
use thiserror::Error;
use crate::qmdl_store::RecordingStoreError; use crate::qmdl_store::RecordingStoreError;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum RayhunterError{ pub enum RayhunterError {
#[error("Config file parsing error: {0}")] #[error("Config file parsing error: {0}")]
ConfigFileParsingError(#[from] toml::de::Error), ConfigFileParsingError(#[from] toml::de::Error),
#[error("Diag intialization error: {0}")] #[error("Diag intialization error: {0}")]

View File

@@ -1,109 +0,0 @@
use image::{codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage};
use std::{io::Cursor, time::Duration};
const FB_PATH:&str = "/dev/fb0";
#[derive(Copy, Clone)]
// TODO actually poll for this, maybe w/ fbset?
struct Dimensions {
height: u32,
width: u32,
}
#[allow(dead_code)]
#[derive(Copy, Clone)]
pub enum Color565 {
Red = 0b1111100000000000,
Green = 0b0000011111100000,
Blue = 0b0000000000011111,
White = 0b1111111111111111,
Black = 0b0000000000000000,
Cyan = 0b0000011111111111,
Yellow = 0b1111111111100000,
Pink = 0b1111010010011111,
}
pub enum DisplayState {
Recording,
Paused,
WarningDetected,
}
impl From<DisplayState> for Color565 {
fn from(state: DisplayState) -> Self {
match state {
DisplayState::Paused => Color565::White,
DisplayState::Recording => Color565::Green,
DisplayState::WarningDetected => Color565::Red,
}
}
}
#[derive(Copy, Clone)]
pub struct Framebuffer<'a> {
dimensions: Dimensions,
path: &'a str,
}
impl Framebuffer<'_>{
pub const fn new() -> Self {
Framebuffer{
dimensions: Dimensions{height: 128, width: 128},
path: FB_PATH,
}
}
fn write(&mut self, img: DynamicImage) {
let mut width = img.width();
let mut height = img.height();
let resized_img: DynamicImage;
if height > self.dimensions.height ||
width > self.dimensions.width {
resized_img = img.resize( self.dimensions.width, self.dimensions.height, FilterType::CatmullRom);
width = self.dimensions.width.min(resized_img.width());
height = self.dimensions.height.min(resized_img.height());
} else {
resized_img = img;
}
let img_rgba8 = resized_img.as_rgba8().unwrap();
let mut buf = Vec::new();
for y in 0..height {
for x in 0..width {
let px = img_rgba8.get_pixel(x, y);
let mut rgb565: u16 = (px[0] as u16 & 0b11111000) << 8;
rgb565 |= (px[1] as u16 & 0b11111100) << 3;
rgb565 |= (px[2] as u16) >> 3;
buf.extend(rgb565.to_le_bytes());
}
}
std::fs::write(self.path, &buf).unwrap();
}
pub fn draw_gif(&mut self, img_buffer: &[u8]) {
// this is dumb and i'm sure there's a better way to loop this
let cursor = Cursor::new(img_buffer);
let decoder = GifDecoder::new(cursor).unwrap();
for maybe_frame in decoder.into_frames() {
let frame = maybe_frame.unwrap();
let (numerator, _) = frame.delay().numer_denom_ms();
let img = DynamicImage::from(frame.into_buffer());
self.write(img);
std::thread::sleep(Duration::from_millis(numerator as u64));
}
}
pub fn draw_img(&mut self, img_buffer: &[u8]) {
let img = image::load_from_memory(img_buffer).unwrap();
self.write(img);
}
pub fn draw_line(&mut self, color: Color565, height: u32){
let px_num= height * self.dimensions.width;
let color: u16 = color as u16;
let mut buffer: Vec<u8> = Vec::new();
for _ in 0..px_num {
buffer.extend(color.to_le_bytes());
}
std::fs::write(self.path, &buffer).unwrap();
}
}

100
bin/src/key_input.rs Normal file
View File

@@ -0,0 +1,100 @@
use log::error;
use std::time::{Duration, Instant};
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use tokio::sync::mpsc::Sender;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::diag::DiagDeviceCtrlMessage;
#[derive(Debug)]
enum Event {
KeyDown,
KeyUp,
}
const INPUT_EVENT_SIZE: usize = 32;
pub fn run_key_input_thread(
task_tracker: &TaskTracker,
config: &config::Config,
diag_tx: Sender<DiagDeviceCtrlMessage>,
) {
if config.key_input_mode == 0 {
return;
}
task_tracker.spawn(async move {
// Open the input device
let mut file = match File::open("/dev/input/event0").await {
Ok(file) => file,
Err(e) => {
error!("Failed to open /dev/input/event0: {}", e);
return;
}
};
let mut buffer = [0u8; INPUT_EVENT_SIZE];
let mut last_keyup: Option<Instant> = None;
loop {
if let Err(e) = file.read_exact(&mut buffer).await {
error!("failed to read key input: {}", e);
return;
}
let event = parse_event(buffer);
match event {
Event::KeyUp => {
if last_keyup.is_some()
&& last_keyup.unwrap().elapsed() < Duration::from_millis(500)
{
if let Err(e) = diag_tx.send(DiagDeviceCtrlMessage::StopRecording).await {
error!("Failed to send StopRecording: {}", e);
}
if let Err(e) = diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await {
error!("Failed to send StartRecording: {}", e);
}
last_keyup = None;
} else {
last_keyup = Some(Instant::now());
}
}
Event::KeyDown => {}
}
}
});
}
fn parse_event(input: [u8; INPUT_EVENT_SIZE]) -> Event {
if input[12] == 0 {
Event::KeyUp
} else {
Event::KeyDown
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_event_keydown_m7350_v5() {
let input = [
0x57, 0x6c, 0x09, 0x00, 0x7c, 0xfb, 0x03, 0x00, 0x01, 0x00, 0x74, 0x00, 0x01, 0x00,
0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
];
assert!(matches!(parse_event(input), Event::KeyDown));
}
#[test]
fn test_parse_event_keyup_m7350_v5() {
let input = [
0x57, 0x6c, 0x09, 0x00, 0x1b, 0x15, 0x05, 0x00, 0x01, 0x00, 0x74, 0x00, 0x00, 0x00,
0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
];
assert!(matches!(parse_event(input), Event::KeyUp));
}
}

View File

@@ -1,36 +1,43 @@
use crate::ServerState; use crate::ServerState;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use futures::TryStreamExt;
use log::error;
use rayhunter::diag::DataType; use rayhunter::diag::DataType;
use rayhunter::gsmtap_parser; use rayhunter::gsmtap_parser;
use rayhunter::pcap::GsmtapPcapWriter; use rayhunter::pcap::GsmtapPcapWriter;
use rayhunter::qmdl::QmdlReader; use rayhunter::qmdl::QmdlReader;
use axum::body::Body; use std::sync::Arc;
use axum::http::header::CONTENT_TYPE; use std::{future, pin::pin};
use axum::extract::{State, Path};
use axum::http::StatusCode;
use axum::response::{Response, IntoResponse};
use tokio::io::duplex; use tokio::io::duplex;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use std::{future, pin::pin};
use std::sync::Arc;
use log::error;
use futures::TryStreamExt;
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data // Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
// written so far. This is done by spawning a thread which streams chunks of // written so far. This is done by spawning a thread which streams chunks of
// pcap data to a channel that's piped to the client. // pcap data to a channel that's piped to the client.
pub async fn get_pcap(State(state): State<Arc<ServerState>>, Path(qmdl_name): Path<String>) -> Result<Response, (StatusCode, String)> { pub async fn get_pcap(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await; let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name) let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or((
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?; StatusCode::NOT_FOUND,
format!("couldn't find qmdl file with name {}", qmdl_name),
))?;
if entry.qmdl_size_bytes == 0 { if entry.qmdl_size_bytes == 0 {
return Err(( return Err((
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
"QMDL file is empty, try again in a bit!".to_string() "QMDL file is empty, try again in a bit!".to_string(),
)); ));
} }
let qmdl_size_bytes = entry.qmdl_size_bytes; let qmdl_size_bytes = entry.qmdl_size_bytes;
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
// the QMDL reader should stop at the last successfully written data chunk // the QMDL reader should stop at the last successfully written data chunk
// (entry.size_bytes) // (entry.size_bytes)
@@ -40,20 +47,27 @@ pub async fn get_pcap(State(state): State<Arc<ServerState>>, Path(qmdl_name): Pa
tokio::spawn(async move { tokio::spawn(async move {
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes)); let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
let mut messages_stream = pin!(reader.as_stream() let mut messages_stream = pin!(reader
.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace))); .try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
while let Some(container) = messages_stream.try_next().await.expect("failed getting QMDL container") { while let Some(container) = messages_stream
.try_next()
.await
.expect("failed getting QMDL container")
{
for maybe_msg in container.into_messages() { for maybe_msg in container.into_messages() {
match maybe_msg { match maybe_msg {
Ok(msg) => { Ok(msg) => {
let maybe_gsmtap_msg = gsmtap_parser::parse(msg) let maybe_gsmtap_msg =
.expect("error parsing gsmtap message"); gsmtap_parser::parse(msg).expect("error parsing gsmtap message");
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg { if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
pcap_writer.write_gsmtap_message(gsmtap_msg, timestamp).await pcap_writer
.write_gsmtap_message(gsmtap_msg, timestamp)
.await
.expect("error writing pcap packet"); .expect("error writing pcap packet");
} }
}, }
Err(e) => error!("error parsing message: {:?}", e), Err(e) => error!("error parsing message: {:?}", e),
} }
} }

View File

@@ -1,6 +1,9 @@
use chrono::{DateTime, Local}; use std::io::{self, ErrorKind};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use chrono::{DateTime, Local};
use rayhunter::util::RuntimeMetadata;
use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use tokio::{ use tokio::{
fs::{self, try_exists, File, OpenOptions}, fs::{self, try_exists, File, OpenOptions},
@@ -11,10 +14,14 @@ use tokio::{
pub enum RecordingStoreError { pub enum RecordingStoreError {
#[error("Can't close an entry when there's no current entry")] #[error("Can't close an entry when there's no current entry")]
NoCurrentEntry, NoCurrentEntry,
#[error("An entry with that name doesn't exist")]
NoSuchEntryError,
#[error("Couldn't create file: {0}")] #[error("Couldn't create file: {0}")]
CreateFileError(tokio::io::Error), CreateFileError(tokio::io::Error),
#[error("Couldn't read file: {0}")] #[error("Couldn't read file: {0}")]
ReadFileError(tokio::io::Error), ReadFileError(tokio::io::Error),
#[error("Couldn't delete file: {0}")]
DeleteFileError(tokio::io::Error),
#[error("Couldn't open directory at path: {0}")] #[error("Couldn't open directory at path: {0}")]
OpenDirError(tokio::io::Error), OpenDirError(tokio::io::Error),
#[error("Couldn't read manifest file: {0}")] #[error("Couldn't read manifest file: {0}")]
@@ -43,17 +50,24 @@ pub struct ManifestEntry {
pub last_message_time: Option<DateTime<Local>>, pub last_message_time: Option<DateTime<Local>>,
pub qmdl_size_bytes: usize, pub qmdl_size_bytes: usize,
pub analysis_size_bytes: usize, pub analysis_size_bytes: usize,
pub rayhunter_version: Option<String>,
pub system_os: Option<String>,
pub arch: Option<String>,
} }
impl ManifestEntry { impl ManifestEntry {
fn new() -> Self { fn new() -> Self {
let now = Local::now(); let now = Local::now();
let metadata = RuntimeMetadata::new();
ManifestEntry { ManifestEntry {
name: format!("{}", now.timestamp()), name: format!("{}", now.timestamp()),
start_time: now, start_time: now,
last_message_time: None, last_message_time: None,
qmdl_size_bytes: 0, qmdl_size_bytes: 0,
analysis_size_bytes: 0, analysis_size_bytes: 0,
rayhunter_version: Some(metadata.rayhunter_version),
system_os: Some(metadata.system_os),
arch: Some(metadata.arch),
} }
} }
@@ -108,23 +122,20 @@ impl RecordingStore {
where where
P: AsRef<Path>, P: AsRef<Path>,
{ {
let manifest_path = path.as_ref().join("manifest.toml");
fs::create_dir_all(&path) fs::create_dir_all(&path)
.await .await
.map_err(RecordingStoreError::OpenDirError)?; .map_err(RecordingStoreError::OpenDirError)?;
let mut manifest_file = File::create(&manifest_path)
.await let mut store = RecordingStore {
.map_err(RecordingStoreError::WriteManifestError)?; path: path.as_ref().to_owned(),
let empty_manifest = Manifest { manifest: Manifest {
entries: Vec::new(), entries: Vec::new(),
},
current_entry: None,
}; };
let empty_manifest_contents =
toml::to_string_pretty(&empty_manifest).expect("failed to serialize manifest"); store.write_manifest().await?;
manifest_file Ok(store)
.write_all(empty_manifest_contents.as_bytes())
.await
.map_err(RecordingStoreError::WriteManifestError)?;
RecordingStore::load(path).await
} }
async fn read_manifest<P>(path: P) -> Result<Manifest, RecordingStoreError> async fn read_manifest<P>(path: P) -> Result<Manifest, RecordingStoreError>
@@ -148,17 +159,11 @@ impl RecordingStore {
} }
let new_entry = ManifestEntry::new(); let new_entry = ManifestEntry::new();
let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path); let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path);
let qmdl_file = File::options() let qmdl_file = File::create(&qmdl_filepath)
.create(true)
.write(true)
.open(&qmdl_filepath)
.await .await
.map_err(RecordingStoreError::CreateFileError)?; .map_err(RecordingStoreError::CreateFileError)?;
let analysis_filepath = new_entry.get_analysis_filepath(&self.path); let analysis_filepath = new_entry.get_analysis_filepath(&self.path);
let analysis_file = File::options() let analysis_file = File::create(&analysis_filepath)
.create(true)
.write(true)
.open(&analysis_filepath)
.await .await
.map_err(RecordingStoreError::CreateFileError)?; .map_err(RecordingStoreError::CreateFileError)?;
self.manifest.entries.push(new_entry); self.manifest.entries.push(new_entry);
@@ -168,10 +173,7 @@ impl RecordingStore {
} }
// Returns the corresponding QMDL file for a given entry // Returns the corresponding QMDL file for a given entry
pub async fn open_entry_qmdl( pub async fn open_entry_qmdl(&self, entry_index: usize) -> Result<File, RecordingStoreError> {
&self,
entry_index: usize,
) -> Result<File, RecordingStoreError> {
let entry = &self.manifest.entries[entry_index]; let entry = &self.manifest.entries[entry_index];
File::open(entry.get_qmdl_filepath(&self.path)) File::open(entry.get_qmdl_filepath(&self.path))
.await .await
@@ -200,8 +202,7 @@ impl RecordingStore {
.open(entry.get_analysis_filepath(&self.path)) .open(entry.get_analysis_filepath(&self.path))
.await .await
.map_err(RecordingStoreError::ReadFileError)?; .map_err(RecordingStoreError::ReadFileError)?;
self.update_entry_analysis_size(entry_index, 0) self.update_entry_analysis_size(entry_index, 0).await?;
.await?;
Ok(file) Ok(file)
} }
@@ -238,23 +239,29 @@ impl RecordingStore {
} }
async fn write_manifest(&mut self) -> Result<(), RecordingStoreError> { async fn write_manifest(&mut self) -> Result<(), RecordingStoreError> {
let mut manifest_file = File::options() let tmp_path = self.path.join("manifest.toml.new");
.write(true) let mut manifest_tmp_file = File::create(&tmp_path)
.open(self.path.join("manifest.toml"))
.await .await
.map_err(RecordingStoreError::WriteManifestError)?; .map_err(RecordingStoreError::WriteManifestError)?;
let manifest_contents = let manifest_contents =
toml::to_string_pretty(&self.manifest).expect("failed to serialize manifest"); toml::to_string_pretty(&self.manifest).expect("failed to serialize manifest");
manifest_file manifest_tmp_file
.write_all(manifest_contents.as_bytes()) .write_all(manifest_contents.as_bytes())
.await .await
.map_err(RecordingStoreError::WriteManifestError)?; .map_err(RecordingStoreError::WriteManifestError)?;
fs::rename(tmp_path, self.path.join("manifest.toml"))
.await
.map_err(RecordingStoreError::WriteManifestError)?;
Ok(()) Ok(())
} }
// Finds an entry by filename // Finds an entry by filename
pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> { pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> {
let entry_index = self.manifest let entry_index = self
.manifest
.entries .entries
.iter() .iter()
.position(|entry| entry.name == name)?; .position(|entry| entry.name == name)?;
@@ -265,6 +272,72 @@ impl RecordingStore {
let entry_index = self.current_entry?; let entry_index = self.current_entry?;
Some((entry_index, &self.manifest.entries[entry_index])) Some((entry_index, &self.manifest.entries[entry_index]))
} }
pub async fn delete_entry(&mut self, name: &str) -> Result<ManifestEntry, RecordingStoreError> {
let entry_to_delete_idx = self
.manifest
.entries
.iter()
.position(|entry| entry.name == name)
.ok_or(RecordingStoreError::NoSuchEntryError)?;
if let Some(current_entry) = self.current_entry {
if current_entry == entry_to_delete_idx {
self.close_current_entry().await?;
} else {
self.current_entry = Some(current_entry - 1);
}
}
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
self.write_manifest().await?;
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
let analysis_filepath = entry_to_delete.get_analysis_filepath(&self.path);
remove_file_if_exists(&qmdl_filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
remove_file_if_exists(&analysis_filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
Ok(entry_to_delete)
}
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
if self.current_entry.is_some() {
self.close_current_entry().await?;
}
let mut keep = Vec::new();
for entry in &self.manifest.entries {
let qmdl_filepath = entry.get_qmdl_filepath(&self.path);
let analysis_filepath = entry.get_analysis_filepath(&self.path);
if let Err(e) = remove_file_if_exists(&qmdl_filepath).await {
log::warn!("failed to remove {qmdl_filepath:?}: {e:?}");
keep.push(true);
continue;
}
if let Err(e) = remove_file_if_exists(&analysis_filepath).await {
log::warn!("failed to remove {analysis_filepath:?}: {e:?}");
keep.push(true);
continue;
}
keep.push(false);
}
let mut keep_iter = keep.into_iter();
self.manifest.entries.retain(|_| keep_iter.next().unwrap());
self.write_manifest().await?;
Ok(())
}
}
async fn remove_file_if_exists(path: &Path) -> Result<(), io::Error> {
match tokio::fs::remove_file(path).await {
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
res => res,
}
} }
#[cfg(test)] #[cfg(test)]
@@ -321,6 +394,20 @@ mod tests {
)); ));
} }
#[tokio::test]
async fn test_create_on_existing_store() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
let _ = store.new_entry().await.unwrap();
let entry_index = store.current_entry.unwrap();
store
.update_entry_qmdl_size(entry_index, 1000)
.await
.unwrap();
let store = RecordingStore::create(dir.path()).await.unwrap();
assert_eq!(store.manifest.entries.len(), 0);
}
#[tokio::test] #[tokio::test]
async fn test_repeated_new_entries() { async fn test_repeated_new_entries() {
let dir = make_temp_dir(); let dir = make_temp_dir();
@@ -332,4 +419,20 @@ mod tests {
assert_ne!(entry_index, new_entry_index); assert_ne!(entry_index, new_entry_index);
assert_eq!(store.manifest.entries.len(), 2); assert_eq!(store.manifest.entries.len(), 2);
} }
#[tokio::test]
async fn test_delete_all_entries() {
let dir = make_temp_dir();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
let _ = store.new_entry().await.unwrap();
assert!(store.current_entry.is_some());
store.delete_all_entries().await.unwrap();
assert!(store.current_entry.is_none());
// regression test: deleting all entries should also work when there's no current
// recording.
store.delete_all_entries().await.unwrap();
assert!(store.current_entry.is_none());
}
} }

View File

@@ -1,80 +1,66 @@
use axum::body::Body; use axum::body::Body;
use axum::http::header::{CONTENT_TYPE, self};
use axum::extract::State;
use axum::http::{StatusCode, HeaderValue};
use axum::response::{Response, IntoResponse};
use axum::extract::Path; use axum::extract::Path;
use tokio::fs::File; use axum::extract::State;
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use include_dir::{include_dir, Dir};
use std::sync::Arc;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use include_dir::{include_dir, Dir};
use crate::{framebuffer, DiagDeviceCtrlMessage};
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus}; use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::RecordingStore;
use crate::{display, DiagDeviceCtrlMessage};
pub struct ServerState { pub struct ServerState {
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>, pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>, pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
pub ui_update_sender: Sender<framebuffer::DisplayState>, pub ui_update_sender: Sender<display::DisplayState>,
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>, pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
pub analysis_sender: Sender<AnalysisCtrlMessage>, pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub debug_mode: bool pub debug_mode: bool,
} }
pub async fn get_qmdl(State(state): State<Arc<ServerState>>, Path(qmdl_name): Path<String>) -> Result<Response, (StatusCode, String)> { pub async fn get_qmdl(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_idx = qmdl_name.trim_end_matches(".qmdl");
let qmdl_store = state.qmdl_store_lock.read().await; let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name) let (entry_index, entry) = qmdl_store.entry_for_name(qmdl_idx).ok_or((
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?; StatusCode::NOT_FOUND,
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await format!("couldn't find qmdl file with name {}", qmdl_idx),
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("error opening QMDL file: {}", e)))?; ))?;
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("error opening QMDL file: {}", e),
)
})?;
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64); let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
let qmdl_stream = ReaderStream::new(limited_qmdl_file); let qmdl_stream = ReaderStream::new(limited_qmdl_file);
let headers = [(CONTENT_TYPE, "application/octet-stream")]; let headers = [
(CONTENT_TYPE, "application/octet-stream"),
(CONTENT_LENGTH, &entry.qmdl_size_bytes.to_string()),
];
let body = Body::from_stream(qmdl_stream); let body = Body::from_stream(qmdl_stream);
Ok((headers, body).into_response()) Ok((headers, body).into_response())
} }
// Bundles the server's static files (html/css/js) into the binary for easy distribution // Bundles the server's static files (html/css/js) into the binary for easy distribution
static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static"); static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/web/build");
pub async fn serve_static(State(state): State<Arc<ServerState>>, Path(path): Path<String>) -> impl IntoResponse { pub async fn serve_static(
State(_): State<Arc<ServerState>>,
Path(path): Path<String>,
) -> impl IntoResponse {
let path = path.trim_start_matches('/'); let path = path.trim_start_matches('/');
let mime_type = mime_guess::from_path(path).first_or_text_plain(); let mime_type = mime_guess::from_path(path).first_or_text_plain();
// if we're in debug mode, return the files from the build directory so we
// don't have to rebuild every time the JS/HTML change
if state.debug_mode {
let mut build_path = std::path::PathBuf::new();
build_path.push("bin");
build_path.push("static");
for part in path.split("/") {
build_path.push(part);
}
return match File::open(build_path).await {
Ok(mut file) => {
let mut body = String::new();
file.read_to_string(&mut body).await.expect("failed to read file");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_str(mime_type.as_ref()).unwrap(),
)
.body(Body::from(body))
.unwrap()
},
Err(_) => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap()
};
}
match STATIC_DIR.get_file(path) { match STATIC_DIR.get_file(path) {
None => Response::builder() None => Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)

View File

@@ -3,10 +3,11 @@ use std::sync::Arc;
use crate::qmdl_store::ManifestEntry; use crate::qmdl_store::ManifestEntry;
use crate::server::ServerState; use crate::server::ServerState;
use axum::Json;
use axum::extract::State; use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use log::error; use log::error;
use rayhunter::util::RuntimeMetadata;
use serde::Serialize; use serde::Serialize;
use tokio::process::Command; use tokio::process::Command;
@@ -14,6 +15,7 @@ use tokio::process::Command;
pub struct SystemStats { pub struct SystemStats {
pub disk_stats: DiskStats, pub disk_stats: DiskStats,
pub memory_stats: MemoryStats, pub memory_stats: MemoryStats,
pub runtime_metadata: RuntimeMetadata,
} }
impl SystemStats { impl SystemStats {
@@ -21,6 +23,7 @@ impl SystemStats {
Ok(Self { Ok(Self {
disk_stats: DiskStats::new(qmdl_path).await?, disk_stats: DiskStats::new(qmdl_path).await?,
memory_stats: MemoryStats::new().await?, memory_stats: MemoryStats::new().await?,
runtime_metadata: RuntimeMetadata::new(),
}) })
} }
} }
@@ -65,10 +68,16 @@ pub struct MemoryStats {
// runs the given command and returns its stdout as a string // runs the given command and returns its stdout as a string
async fn get_cmd_output(mut cmd: Command) -> Result<String, String> { async fn get_cmd_output(mut cmd: Command) -> Result<String, String> {
let cmd_str = format!("{:?}", &cmd); let cmd_str = format!("{:?}", &cmd);
let output = cmd.output().await let output = cmd
.output()
.await
.map_err(|e| format!("error running command {}: {}", &cmd_str, e))?; .map_err(|e| format!("error running command {}: {}", &cmd_str, e))?;
if !output.status.success() { if !output.status.success() {
return Err(format!("command {} failed with exit code {}", &cmd_str, output.status.code().unwrap())); return Err(format!(
"command {} failed with exit code {}",
&cmd_str,
output.status.code().unwrap()
));
} }
Ok(String::from_utf8_lossy(&output.stdout).to_string()) Ok(String::from_utf8_lossy(&output.stdout).to_string())
} }
@@ -79,7 +88,8 @@ impl MemoryStats {
let mut free_cmd = Command::new("free"); let mut free_cmd = Command::new("free");
free_cmd.arg("-k"); free_cmd.arg("-k");
let stdout = get_cmd_output(free_cmd).await?; let stdout = get_cmd_output(free_cmd).await?;
let mut numbers = stdout.split_whitespace() let mut numbers = stdout
.split_whitespace()
.flat_map(|part| part.parse::<usize>()); .flat_map(|part| part.parse::<usize>());
Ok(Self { Ok(Self {
total: humanize_kb(numbers.next().ok_or("error parsing free output")?), total: humanize_kb(numbers.next().ok_or("error parsing free output")?),
@@ -91,13 +101,15 @@ impl MemoryStats {
// turns a number of kilobytes (like 28293) into a human-readable string (like "28.3M") // turns a number of kilobytes (like 28293) into a human-readable string (like "28.3M")
fn humanize_kb(kb: usize) -> String { fn humanize_kb(kb: usize) -> String {
if kb < 1000{ if kb < 1000 {
return format!("{}K", kb); return format!("{}K", kb);
} }
format!("{:.1}M", kb as f64 / 1024.0) format!("{:.1}M", kb as f64 / 1024.0)
} }
pub async fn get_system_stats(State(state): State<Arc<ServerState>>) -> Result<Json<SystemStats>, (StatusCode, String)> { 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; 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()).await {
Ok(stats) => Ok(Json(stats)), Ok(stats) => Ok(Json(stats)),
@@ -105,9 +117,9 @@ pub async fn get_system_stats(State(state): State<Arc<ServerState>>) -> Result<J
error!("error getting system stats: {}", err); error!("error getting system stats: {}", err);
Err(( Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"error getting system stats".to_string() "error getting system stats".to_string(),
)) ))
}, }
} }
} }
@@ -117,7 +129,9 @@ pub struct ManifestStats {
pub current_entry: Option<ManifestEntry>, pub current_entry: Option<ManifestEntry>,
} }
pub async fn get_qmdl_manifest(State(state): State<Arc<ServerState>>) -> Result<Json<ManifestStats>, (StatusCode, String)> { pub async fn get_qmdl_manifest(
State(state): State<Arc<ServerState>>,
) -> Result<Json<ManifestStats>, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await; let qmdl_store = state.qmdl_store_lock.read().await;
let mut entries = qmdl_store.manifest.entries.clone(); let mut entries = qmdl_store.manifest.entries.clone();
let current_entry = qmdl_store.current_entry.map(|index| entries.remove(index)); let current_entry = qmdl_store.current_entry.map(|index| entries.remove(index));

View File

@@ -1,45 +0,0 @@
td,
th {
border: 1px solid rgb(190, 190, 190);
padding: 10px;
}
td {
text-align: center;
}
tr:nth-child(even) {
background-color: #eee;
}
th[scope='col'] {
background-color: #696969;
color: #fff;
}
th[scope='row'] {
background-color: #d7d9f2;
}
tr.current {
background-color: #53fe7b;
font-weight: bold;
}
tr.warning {
background-color: #fe537b;
font-weight: bold;
}
caption {
padding: 10px;
caption-side: bottom;
}
table {
border-collapse: collapse;
border: 2px solid rgb(200, 200, 200);
letter-spacing: 1px;
font-family: sans-serif;
font-size: 0.8rem;
}

View File

@@ -1,43 +0,0 @@
<html>
<head>
<title>rayhunter</title>
<link rel="stylesheet" type="text/css" href="css/style.css">
<script src="js/main.js"></script>
<script>
async function repeatedlyPopulate() {
await populateDivs();
setTimeout(repeatedlyPopulate, 1000);
}
window.onload = function() {
repeatedlyPopulate();
}
</script>
</head>
<body>
<div>
<button onclick="startRecording()">Start Recording</button>
<button onclick="stopRecording()">Stop Recording</button>
</div>
<table id="qmdl-manifest-table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Date Started</th>
<th scope="col">Date of Last Message</th>
<th scope="col">Size (bytes)</th>
<th scope="col">PCAP</th>
<th scope="col">QMDL</th>
<th scope="col">Analysis Result</th>
</tr>
</thead>
</table>
<div>
<h3>Live System stats</h3>
<pre id="system-stats">Loading...</pre>
</div>
<div>
<h3>Analysis Report of Current Capture</h3>
<pre id="analysis-report">Loading...</pre>
</div>
</body>
</html>

View File

@@ -1,206 +0,0 @@
const STATUS_RUNNING = 'running';
const STATUS_QUEUED = 'queued';
const STATUS_NEEDS_UPDATE = 'needs-update';
const STATUS_COMPLETE = 'complete';
async function populateDivs() {
const systemStats = await getSystemStats();
const systemStatsDiv = document.getElementById('system-stats');
systemStatsDiv.innerHTML = JSON.stringify(systemStats, null, 2);
const analysisReportDiv = document.getElementById('analysis-report');
try {
const analysisReport = await getAnalysisReport('live');
analysisReportDiv.innerHTML = JSON.stringify(analysisReport, null, 2);
} catch (e) {
analysisReportDiv.innerHTML = e.toString();
}
const qmdlManifest = await getQmdlManifest();
await updateAnalysisStatus(qmdlManifest);
await updateAnalysisResults(qmdlManifest);
updateQmdlManifestTable(qmdlManifest);
}
function setStatus(qmdlManifest, name, status) {
// ignore qmdlManifest.current_entry, it's always running
for (const entry of qmdlManifest.entries) {
if (entry.name === name) {
entry['status'] = status;
return;
}
}
}
async function updateAnalysisStatus(qmdlManifest) {
const status = JSON.parse(await req('GET', '/api/analysis'));
if (status.running) {
setStatus(qmdlManifest, status.running, STATUS_RUNNING);
}
for (const queued in status.queued) {
setStatus(qmdlManifest, queued, STATUS_QUEUED);
}
}
function parseNewlineDelimitedJSON(inputStr) {
const lines = inputStr.split('\n');
const result = [];
let currentLine = '';
while (lines.length > 0) {
currentLine += lines.shift();
try {
const entry = JSON.parse(currentLine);
result.push(entry);
currentLine = '';
// if this chunk wasn't valid JSON, there was an escaped newline in the
// JSON line, so simply continue to the next one
} catch (e) {}
}
return result;
}
async function updateEntryAnalysisResult(entry) {
entry.analysis = {
warnings: [],
};
const report = parseNewlineDelimitedJSON(await req('GET', `/api/analysis-report/${entry.name}`));
for (const row of report) {
if (row["analysis"]) {
const timestamp = new Date(row["timestamp"]);
const analysis = row["analysis"];
for (const warning of analysis) {
entry.analysis.warnings.push({
timestamp,
warning,
})
}
}
}
if (entry.analysis.warnings.length === 0) {
entry.analysis_result = `0 warnings!`;
} else {
entry.analysis_result = `!!! ${entry.analysis.warnings.length} warnings !!!`;
}
}
async function updateAnalysisResults(qmdlManifest) {
if (qmdlManifest.current_entry) {
await updateEntryAnalysisResult(qmdlManifest.current_entry);
}
for (const entry of qmdlManifest.entries) {
if (entry.status === STATUS_NEEDS_UPDATE) {
await updateEntryAnalysisResult(entry);
entry.status = STATUS_COMPLETE;
}
}
}
function updateQmdlManifestTable(manifest) {
const table = document.getElementById('qmdl-manifest-table');
const numRows = table.rows.length;
for (let i=1; i<numRows; i++) {
table.deleteRow(1);
}
if (manifest.current_entry) {
const row = createEntryRow(manifest.current_entry, true);
row.classList.add('current');
table.appendChild(row)
}
for (let entry of manifest.entries) {
table.appendChild(createEntryRow(entry), false);
}
}
function createLink(uri, text) {
const link = document.createElement('a');
link.href = uri;
link.innerText = text;
return link;
}
function createEntryRow(entry, isCurrent) {
const row = document.createElement('tr');
const name = document.createElement('th');
name.scope = 'row';
name.innerText = entry.name;
row.appendChild(name);
for (const key of ['start_time', 'last_message_time', 'qmdl_size_bytes']) {
const td = document.createElement('td');
td.innerText = entry[key];
row.appendChild(td);
}
const pcapTd = document.createElement('td');
pcapTd.appendChild(createLink(`/api/pcap/${entry.name}`, 'pcap'));
row.appendChild(pcapTd);
const qmdlTd = document.createElement('td');
qmdlTd.appendChild(createLink(`/api/qmdl/${entry.name}`, 'qmdl'));
row.appendChild(qmdlTd);
const analysisResult = document.createElement('td');
analysisResult.innerText = entry.analysis_result;
if (entry.analysis.warnings.length > 0) {
row.classList.add("warning");
}
row.appendChild(analysisResult);
return row;
}
async function getAnalysisReport(name) {
const rows = await req('GET', `/api/analysis-report/${name}`);
return rows.split('\n')
.filter(row => row.length > 0)
.map(row => JSON.parse(row));
}
async function getSystemStats() {
return JSON.parse(await req('GET', '/api/system-stats'));
}
async function getQmdlManifest() {
const manifest = JSON.parse(await req('GET', '/api/qmdl-manifest'));
if (manifest.current_entry) {
manifest.current_entry.status = STATUS_NEEDS_UPDATE;
manifest.current_entry.analysis_result = 'Waiting...';
manifest.current_entry.start_time = new Date(manifest.current_entry.start_time);
if (manifest.current_entry.last_message_time === undefined) {
manifest.current_entry.last_message_time = "N/A";
} else {
manifest.current_entry.last_message_time = new Date(manifest.current_entry.last_message_time);
}
}
for (entry of manifest.entries) {
entry.status = STATUS_NEEDS_UPDATE;
entry.analysis_result = 'Waiting...';
entry.start_time = new Date(entry.start_time);
entry.last_message_time = new Date(entry.last_message_time);
}
// sort them in reverse chronological order
manifest.entries.reverse();
return manifest;
}
async function startRecording() {
await req('POST', '/api/start-recording');
populateDivs();
}
async function stopRecording() {
await req('POST', '/api/stop-recording');
populateDivs();
}
async function req(method, url) {
const response = await fetch(url, {
method: method,
});
const body = await response.text();
if (response.status >= 200 && response.status < 300) {
return body;
} else {
throw new Error(body);
}
}

24
bin/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
node_modules
# Output
.output
.vercel
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
package-lock.json
yarn.lock

1
bin/web/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

4
bin/web/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

17
bin/web/.prettierrc Normal file
View File

@@ -0,0 +1,17 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte"
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

33
bin/web/eslint.config.js Normal file
View File

@@ -0,0 +1,33 @@
import prettier from "eslint-config-prettier";
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import ts from 'typescript-eslint';
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs["flat/recommended"],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ["build/", ".svelte-kit/", "dist/"]
}
);

37
bin/web/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "web",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/eslint": "^9.6.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.9",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^5.0.3",
"vitest": "^2.0.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

3
bin/web/src/app.css Normal file
View File

@@ -0,0 +1,3 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities"

13
bin/web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
bin/web/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { EventType, parse_finished_report, Severity, type QualitativeWarning } from './analysis.svelte';
import { parse_ndjson, type NewlineDeliminatedJson } from './ndjson';
const SAMPLE_REPORT_NDJSON: NewlineDeliminatedJson = [
{ "analyzers": [{ "name": "LTE SIB 6/7 Downgrade", "description": "Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities." }, { "name": "IMSI Provided", "description": "Tests whether the UE's IMSI was ever provided to the cell" }, { "name": "Null Cipher", "description": "Tests whether the cell suggests using a null cipher (EEA0)" }, { "name": "Example Analyzer", "description": "Always returns true, if you are seeing this you are either a developer or you are about to have problems." }] },
{ "timestamp": "2024-10-08T13:25:43.011689003-07:00", "skipped_message_reasons": ["DecodingError(UperDecodeError(Error { cause: BufferTooShort, msg: \"PerCodec:DecodeError:Requested Bits to decode 3, Remaining bits 1\", context: [] }))"], "analysis": [] },
{ "timestamp": "2024-10-08T13:25:43.480872496-07:00", "skipped_message_reasons": [], "analysis": [{ "timestamp": "2024-08-19T03:33:54.318Z", "events": [null, null, null, { "event_type": { "type": "QualitativeWarning", "severity": "Low" }, "message": "TMSI was provided to cell" }] }] },
];
describe('analysis report parsing', () => {
it('parses the example analysis', () => {
const report = parse_finished_report(SAMPLE_REPORT_NDJSON);
expect(report.metadata.analyzers).toEqual([
{
"name":"LTE SIB 6/7 Downgrade",
"description":"Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities.",
},
{
"name":"IMSI Provided",
"description":"Tests whether the UE's IMSI was ever provided to the cell",
},
{
"name":"Null Cipher",
"description":"Tests whether the cell suggests using a null cipher (EEA0)",
},
{
"name":"Example Analyzer",
"description":"Always returns true, if you are seeing this you are either a developer or you are about to have problems.",
}
]);
expect(report.rows).toHaveLength(2);
expect(report.rows[0].skipped_message_reasons).toHaveLength(1);
expect(report.rows[0].analysis).toHaveLength(0);
expect(report.rows[1].skipped_message_reasons).toHaveLength(0);
expect(report.rows[1].analysis).toHaveLength(1);
expect(report.rows[1].analysis[0].events).toHaveLength(1);
const event = report.rows[1].analysis[0].events[0];
if (event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
});
});

View File

@@ -0,0 +1,118 @@
import { parse_ndjson, type NewlineDeliminatedJson } from "./ndjson";
import { req } from "./utils.svelte";
export type AnalysisReport = {
metadata: ReportMetadata;
rows: AnalysisRow[];
statistics: ReportStatistics;
};
export type ReportStatistics = {
num_warnings: number;
num_informational_logs: number;
num_skipped_packets: number;
}
export type ReportMetadata = {
analyzers: AnalyzerMetadata[];
rayhunter: RayhunterMetadata;
};
export type RayhunterMetadata = {
rayhunter_version: string;
system_os: string;
arch: string;
};
export type AnalyzerMetadata = {
name: string;
description: string;
};
export type AnalysisRow = {
timestamp: Date;
skipped_message_reasons: string[];
analysis: PacketAnalysis[];
};
export type PacketAnalysis = {
timestamp: Date;
events: Event[];
};
export type Event = QualitativeWarning | InformationalEvent;
export enum EventType {
Informational,
Warning,
}
export type QualitativeWarning = {
type: EventType.Warning;
severity: Severity;
message: string;
};
export enum Severity {
Low,
Medium,
High,
}
export type InformationalEvent = {
type: EventType.Informational;
message: string;
};
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
const metadata: ReportMetadata = report_json[0]; // this can be cast directly
let num_warnings = 0;
let num_informational_logs = 0;
let num_skipped_packets = 0;
const rows: AnalysisRow[] = report_json.slice(1).map((row_json: any) => {
const analysis: PacketAnalysis[] = row_json.analysis.map((analysis_json: any) => {
const events: Event[] = analysis_json.events.map((event_json: any): Event | null => {
if (event_json === null) {
return null;
} else if (event_json.event_type.type === "Informational") {
num_informational_logs += 1;
return {
type: EventType.Informational,
message: event_json.message,
};
} else {
num_warnings += 1;
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,
};
}
})
.filter((maybe_event: Event | null) => maybe_event !== null);
return {
timestamp: analysis_json.timestamp,
events,
};
});
num_skipped_packets += row_json.skipped_message_reasons.length;
return {
timestamp: new Date(row_json.timestamp),
skipped_message_reasons: row_json.skipped_message_reasons,
analysis,
};
});
return {
statistics: {
num_informational_logs,
num_warnings,
num_skipped_packets,
},
metadata,
rows,
};
}
export async function get_report(name: string): Promise<AnalysisReport> {
const report_json = parse_ndjson(await req('GET', `/api/analysis-report/${name}`));
return parse_finished_report(report_json);
}

View File

@@ -0,0 +1,63 @@
import { get_report, type AnalysisReport } from "./analysis.svelte";
import type { Manifest, ManifestEntry } from "./manifest.svelte";
import { req } from "./utils.svelte";
export enum AnalysisStatus {
// rayhunter is currently analyzing this entry (note that this is distinct
// from the currently-recording entry)
Running,
// this entry is queued to be analyzed
Queued,
// analysis is finished, and the new report can be accessed
Finished,
}
type AnalysisStatusJson = {
running: string | null;
queued: string[];
finished: string[];
};
export type AnalysisResult = {
name: string,
status: AnalysisStatus,
};
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}`);
this.status.set(name, AnalysisStatus.Queued);
this.reports.delete(name);
}
public async update() {
const status: AnalysisStatusJson = JSON.parse(await req('GET', '/api/analysis'));
if (status.running) {
this.status.set(status.running, AnalysisStatus.Running);
}
for (const entry of status.queued) {
this.status.set(entry, AnalysisStatus.Queued);
}
for (const entry of status.finished) {
// if entry was already finished, nothing to do
if (this.status.get(entry) === AnalysisStatus.Finished) {
continue;
}
this.status.set(entry, AnalysisStatus.Finished);
// fetch the analysis report
this.reports.delete(entry);
get_report(entry).then(report => {
this.reports.set(entry, report);
}).catch(err => {
this.reports.set(entry, `Failed to get analysis: ${err}`);
});
}
}
}

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { AnalysisStatus } from "$lib/analysisManager.svelte";
import { EventType } from "$lib/analysis.svelte";
import type { ManifestEntry } from "$lib/manifest.svelte";
let { entry, onclick, analysis_visible}: {
entry: ManifestEntry,
onclick: () => void,
analysis_visible: boolean,
} = $props();
let summary = $derived.by(() => {
if (entry.analysis_status === AnalysisStatus.Queued) {
return 'Queued...';
} else if (entry.analysis_status === AnalysisStatus.Running) {
return 'Running...';
} else if (entry.analysis_status === AnalysisStatus.Finished) {
if (entry.analysis_report === undefined) {
return 'Loading...';
} else if (typeof(entry.analysis_report) === 'string') {
return entry.analysis_report;
} else {
let num_warnings = 0;
for (let row of entry.analysis_report.rows) {
for (let analysis of row.analysis) {
for (let event of analysis.events) {
if (event.type === EventType.Warning) {
num_warnings += 1;
}
}
}
}
return `${num_warnings} warnings`;
}
} else {
return 'Loading...';
}
});
let ready = $derived.by(() => {
let finished = entry.analysis_status === AnalysisStatus.Finished;
let report_available = entry.analysis_report !== undefined;
return finished && report_available;
})
let button_class = $derived(ready ? "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() < 1 ? 'text-green-700 border-green-500 bg-green-200' : 'text-red-700 border-red-500 bg-red-200'}">{summary}</span>
<svg class="w-6 h-6 text-gray-800 transition-transform {analysis_visible ? 'rotate-180' : ''}" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/>
</svg>
</button>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { AnalysisStatus } from "$lib/analysisManager.svelte";
import { EventType, type AnalyzerMetadata, type ReportMetadata, type AnalysisRow, type AnalysisReport } from "$lib/analysis.svelte";
import type { ManifestEntry } from "$lib/manifest.svelte";
let { report }: {
report: AnalysisReport,
} = $props();
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long",
dateStyle: "short",
});
const skipped_messages: Map<string, number> = $derived.by(() => {
let map = new Map();
for (const row of report.rows) {
for (const message of row.skipped_message_reasons) {
let count = map.get(message);
if (count === undefined) {
count = 0;
}
map.set(message, count + 1);
}
}
return map;
});
</script>
<div>
<p class="text-lg underline">Warnings and Informational Logs</p>
{#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0}
<p>Nothing to show!</p>
{:else}
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th class="p-2">Timestamp</th>
<th class="p-2">Warning</th>
<th class="p-2">Severity</th>
</tr>
</thead>
<tbody>
{#each report.rows as row, row_idx}
{#each row.analysis as analysis}
{@const parsed_date = new Date(analysis.timestamp)}
{#each analysis.events.filter(e => e !== null) as event}
<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]}
<td class="p-2">{date_formatter.format(parsed_date)}</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">{event.message}</td>
<td class="p-2">Info</td>
{/if}
</tr>
{/each}
{/each}
{/each}
</tbody>
</table>
{/if}
</div>
{#if report.statistics.num_skipped_packets > 0}
<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 problem.</p>
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th scope="col" class="p-2">Total Msgs Affected</th>
<th scope="col">Reason/Error</th>
</tr>
</thead>
<tbody>
{#each skipped_messages.entries() as [message, count]}
<tr class="even:bg-gray-200 odd:bg-white">
<td class="text-center">{count}</td>
<td>{message}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { AnalysisStatus } from "$lib/analysisManager.svelte";
import { EventType, type AnalyzerMetadata, type ReportMetadata, type AnalysisRow } from "$lib/analysis.svelte";
import type { ManifestEntry } from "$lib/manifest.svelte";
import AnalysisTable from "./AnalysisTable.svelte";
let { entry }: {
entry: ManifestEntry,
} = $props();
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long",
dateStyle: "short",
});
</script>
<div class="container mt-2">
{#if entry.analysis_report === undefined}
<p>Report unavailable, try refreshing.</p>
{:else if typeof(entry.analysis_report) === 'string'}
<p>Error getting analysis report: {entry.analysis_report}</p>
{:else}
{@const metadata: ReportMetadata = entry.analysis_report.metadata}
<div class="flex flex-col gap-2">
{#if entry.analysis_report.rows.length > 0}
<AnalysisTable report={entry.analysis_report} />
{:else}
<p>No warnings to display!</p>
{/if}
{#if metadata !== undefined && metadata.rayhunter !== undefined}
<div>
<p class="text-lg underline">Metadata</p>
<p>Analysis by Rayhunter version {metadata.rayhunter.rayhunter_version}</p>
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p>
</div>
<div>
<p class="text-lg underline">Analyzers</p>
{#each metadata.analyzers as analyzer}
<p><b>{analyzer.name}:</b> {analyzer.description}</p>
{/each}
</div>
{:else}
<p>N/A (analysis generated by an older version of rayhunter)</p>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { req } from "$lib/utils.svelte";
import DeleteButton from "./DeleteButton.svelte";
function confirmDelete() {
if (window.confirm(`Permanently delete ALL recordings?`)) {
req('POST', '/api/delete-all-recordings')
}
}
</script>
<div class="flex flex-row justify-end gap-2">
<DeleteButton
text="Delete ALL Recordings"
prompt={`Are you sure you want to delete ALL recordings?`}
url={`/api/delete-all-recordings`}
/>
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte";
import { req } from "$lib/utils.svelte";
let { text, url, prompt }: {
text?: string,
url: string,
prompt: string,
} = $props();
function confirmDelete() {
if (window.confirm(prompt)) {
req('POST', url)
}
}
</script>
<button class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-md flex flex-row" onclick={confirmDelete} aria-label="delete">
<p>{text}</p>
<svg
style="width:24px;height:24px"
viewBox="0 0 24 24"
>
<path
fill="white"
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
/>
</svg>
</button>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
let { url, text, full_button=false }: {
url: string;
text: string;
full_button?: boolean;
} = $props();
function download() {
window.location.href = url;
}
</script>
<button class="flex flex-row {full_button ? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 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>

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte";
import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from "$lib/components/DeleteButton.svelte";
import AnalysisStatus from "./AnalysisStatus.svelte";
import AnalysisView from "./AnalysisView.svelte";
import RecordingControls from "./RecordingControls.svelte";
let { entry, current, i, server_is_recording }: {
entry: ManifestEntry;
current: boolean;
i: number;
server_is_recording: boolean;
} = $props();
// passing `undefined` as the locale uses the browser default
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long",
dateStyle: "short",
});
let status_row_color = $derived.by(() => {
const num_warnings = entry.get_num_warnings();
if (num_warnings !== undefined && num_warnings > 0) {
return "bg-red-100";
}
return current ? "bg-green-100" : "bg-gray-100"
});
let status_border_color = $derived.by(() => {
const num_warnings = entry.get_num_warnings();
if (num_warnings !== undefined && num_warnings > 0) {
return "border-red-100";
}
return current ? "border-green-100" : "border-gray-100"
});
let analysis_visible = $state(false);
function toggle_analysis_visibility() {
analysis_visible = !analysis_visible;
}
</script>
<div class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1">
{#if current}
<div class="flex flex-row justify-between gap-2">
<span class="text-xl mb-2">Current Recording</span>
<span class=""><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></span>
</div>
{/if}
<div class="flex flex-col">
<div class="flex flex-row justify-between">
<span class="font-bold">ID: {entry.name}</span>
{#if !current}
<span class=""><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></span>
{/if}
</div>
<span class="">{entry.get_readable_qmdl_size()}</span>
</div>
<div class="flex flex-col">
<span class="">Start: {date_formatter.format(entry.start_time)}</span>
<span class="">Last Message: {date_formatter.format(entry.last_message_time)}</span>
</div>
<div class="flex flex-row justify-between lg:justify-end gap-2 mt-2">
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button=true />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button=true />
{#if current}
<RecordingControls {server_is_recording} />
{:else}
<DeleteButton
prompt={`Are you sure you want to delete entry ${entry.name}?`}
url={entry.get_delete_url()}
/>
{/if}
</div>
<div class="border-b {analysis_visible ? '' : 'hidden'}">
<AnalysisView {entry} />
</div>
</div>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { Manifest, ManifestEntry } from "$lib/manifest.svelte";
import TableRow from "./ManifestTableRow.svelte";
import Card from "./ManifestCard.svelte"
interface Props {
entries: ManifestEntry[];
server_is_recording: boolean;
}
let { entries, server_is_recording }: Props = $props();
</script>
<!--For larger screens we use a table-->
<table class="hidden table-auto text-left lg:table">
<thead>
<tr class="bg-gray-100 drop-shadow">
<th class='p-2' scope="col">ID</th>
<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">Analysis</th>
<th class='p-2' scope="col"></th>
</tr>
</thead>
<tbody>
{#each entries as entry, i}
<TableRow {entry} current={false} {i} />
{/each}
</tbody>
</table>
<!--For smaller screens we use cards-->
<div class="lg:hidden flex flex-col gap-4">
{#each entries as entry, i}
<Card {entry} current={false} {i} />
{/each}
</div>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte";
import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from "$lib/components/DeleteButton.svelte";
import AnalysisStatus from "./AnalysisStatus.svelte";
import AnalysisView from "./AnalysisView.svelte";
let { entry, current, i }: {
entry: ManifestEntry;
current: boolean;
i: number;
} = $props();
// passing `undefined` as the locale uses the browser default
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "long",
dateStyle: "short",
});
let alternating_row_color = $derived(i % 2 == 0 ? "bg-white" : "bg-gray-100");
let status_row_color = $derived.by(() => {
const num_warnings = entry.get_num_warnings();
if (num_warnings !== undefined && num_warnings > 0) {
return "bg-red-100";
}
return current ? "bg-green-100" : alternating_row_color
});
let analysis_visible = $state(false);
function toggle_analysis_visibility() {
analysis_visible = !analysis_visible;
}
</script>
<tr class="{status_row_color} drop-shadow">
<td class="p-2">{entry.name}</td>
<td class="p-2">{date_formatter.format(entry.start_time)}</td>
<td class="p-2">{date_formatter.format(entry.last_message_time)}</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"><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></td>
{#if current}
<td class="p-2"></td>
{:else}
<td class="p-2">
<DeleteButton
prompt={`Are you sure you want to delete entry ${entry.name}?`}
url={entry.get_delete_url()}
/>
</td>
{/if}
</tr>
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
<td class="border-t border-dashed p-2" colspan="8">
<AnalysisView {entry} />
</td>
</tr>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { req } from "$lib/utils.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-4 rounded-md flex flex-row gap-1";
</script>
<div>
{#if waiting_for_server}
<button class={server_is_recording ? stop_recording_classes : start_recording_classes}>
{server_is_recording ? "Stopping..." : "Starting..."}
</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>
{:else}
<button class="{recording_button_classes} bg-blue-500 hover:bg-blue-700" onclick={start_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>
{/if}
</div>
<style>
</style>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { type SystemStats } from "$lib/systemStats";
let { stats }: {
stats: SystemStats;
} = $props();
const table_cell_classes = "border p-1 lg:p-2";
</script>
<div class="flex-1 drop-shadow p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100">
<p class="text-xl mb-2">System Information</p>
<table class="table-auto border">
<tbody>
<tr class="border">
<th class={table_cell_classes}>
Rayhunter Version
</th>
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td>
</tr>
<tr class="border">
<th class={table_cell_classes}>
Storage
</th>
<td class={table_cell_classes}>
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats.disk_stats.available_size} available)
</td>
</tr>
<tr class="border-b">
<th class={table_cell_classes}>
Memory (RAM)
</th>
<td class={table_cell_classes}>
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
</td>
</tr>
</tbody>
</table>
</div>

1
bin/web/src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,103 @@
import { get_report, type AnalysisReport } from "./analysis.svelte";
import { AnalysisStatus, type AnalysisManager } from "./analysisManager.svelte";
interface JsonManifest {
entries: JsonManifestEntry[];
current_entry: JsonManifestEntry | null;
}
interface JsonManifestEntry {
name: string;
start_time: string;
last_message_time: string;
qmdl_size_bytes: number;
analysis_size_bytes: number;
}
export class Manifest {
public entries: ManifestEntry[] = [];
public current_entry: ManifestEntry | undefined;
constructor(json: JsonManifest) {
for (let entry of json.entries) {
this.entries.push(new ManifestEntry(entry));
}
if (json.current_entry !== null) {
this.current_entry = new ManifestEntry(json['current_entry']);
}
// sort entries in reverse chronological order
this.entries.reverse();
}
async set_analysis_status(manager: AnalysisManager) {
for (let entry of this.entries) {
entry.analysis_status = manager.status.get(entry.name);
entry.analysis_report = manager.reports.get(entry.name);
}
if (this.current_entry) {
try {
this.current_entry.analysis_report = await get_report(this.current_entry.name);
} catch(err) {
this.current_entry.analysis_report = `Err: failed to get analysis report: ${err}`;
}
// the current entry should always be considered "finished", as its
// analysis report is always available
this.current_entry.analysis_status = AnalysisStatus.Finished;
}
}
}
export class ManifestEntry {
public name = $state("");
public start_time: Date;
public last_message_time: Date | undefined = $state(undefined);
public qmdl_size_bytes = $state(0);
public analysis_size_bytes = $state(0);
public analysis_status: AnalysisStatus | undefined = $state(undefined);
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
constructor(json: JsonManifestEntry) {
this.name = json.name;
this.qmdl_size_bytes = json.qmdl_size_bytes;
this.analysis_size_bytes = json.analysis_size_bytes;
this.start_time = new Date(json.start_time);
if (json.last_message_time !== undefined) {
this.last_message_time = new Date(json.last_message_time);
}
}
get_readable_qmdl_size(): string {
if (this.qmdl_size_bytes === 0) return "0 Bytes";
const k = 1024;
const dm = 2 || 2;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(this.qmdl_size_bytes) / Math.log(k));
return `${Number.parseFloat((this.qmdl_size_bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}
get_num_warnings(): number | undefined {
if (this.analysis_report === undefined || typeof(this.analysis_report) === 'string') {
return undefined;
}
return this.analysis_report.statistics.num_warnings;
}
get_pcap_url(): string {
return `/api/pcap/${this.name}`;
}
get_qmdl_url(): string {
return `/api/qmdl/${this.name}`;
}
get_analysis_report_url(): string {
return `/api/analysis-report/${this.name}`;
}
get_delete_url(): string {
return `/api/delete-recording/${this.name}`;
}
}

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { parse_ndjson } from './ndjson';
describe('parsing newline-deliminated json', () => {
it('parses normal JSON', () => {
const json = JSON.stringify({ foo: 100 });
const result = parse_ndjson(json);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ foo: 100 });
});
it('parses simple newline-deliminated json', () => {
const json_a = JSON.stringify({ a: 100 });
const json_b = JSON.stringify({ b: 200 });
const result = parse_ndjson(`${json_a}\n${json_b}`);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ a: 100 });
expect(result[1]).toEqual({ b: 200 });
})
it('parses newline-deliminated json with escaped newlines within', () => {
const json_a = JSON.stringify({ a: 'this one has\n newlines and\nstuff' });
const json_b = JSON.stringify({ b: 200 });
const result = parse_ndjson(`${json_a}\n${json_b}`);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ a: 'this one has\n newlines and\nstuff' });
expect(result[1]).toEqual({ b: 200 });
})
it('actually errors out on invalid ndjson', () => {
expect(() => parse_ndjson("invalid\njson")).toThrow();
});
});

27
bin/web/src/lib/ndjson.ts Normal file
View File

@@ -0,0 +1,27 @@
export type NewlineDeliminatedJson = any[];
export function parse_ndjson(input: string): NewlineDeliminatedJson {
const lines = input.split('\n');
const result = [];
let current_line = '';
while (lines.length > 0) {
current_line += lines.shift();
if (current_line.length === 0) {
continue;
}
try {
const entry = JSON.parse(current_line);
result.push(entry);
current_line = '';
} catch (e) {
// if this chunk wasn't valid JSON, assume there was an escaped
// newline in the JSON line, so simply continue to the next one.
// however, if we've reached the end of the input, that means we
// were given invalid nd-json
if (lines.length === 0) {
throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`);
}
}
}
return result;
}

View File

@@ -0,0 +1,26 @@
export interface SystemStats {
disk_stats: DiskStats;
memory_stats: MemoryStats;
runtime_metadata: RuntimeMetadata;
}
export interface RuntimeMetadata {
rayhunter_version: string,
system_os: string,
arch: string,
}
export interface DiskStats {
partition: string,
total_size: string,
used_size: string,
available_size: string,
used_percent: string,
mounted_on: string,
}
export interface MemoryStats {
total: string,
used: string,
free: string,
}

View File

@@ -0,0 +1,23 @@
import { Manifest } from "./manifest.svelte";
import type { SystemStats } from "./systemStats";
export async function req(method: string, url: string): Promise<string> {
const response = await fetch(url, {
method: method,
});
const body = await response.text();
if (response.status >= 200 && response.status < 300) {
return body;
} else {
throw new Error(body);
}
}
export async function get_manifest(): Promise<Manifest> {
const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest'));
return new Manifest(manifest_json);
}
export async function get_system_stats(): Promise<SystemStats> {
return JSON.parse(await req('GET', '/api/system-stats'));
}

View File

@@ -0,0 +1 @@
export const prerender = true;

View File

@@ -0,0 +1,6 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import { ManifestEntry } from "$lib/manifest.svelte";
import { get_manifest, get_system_stats } from "$lib/utils.svelte";
import ManifestTable from "$lib/components/ManifestTable.svelte";
import Card from "$lib/components/ManifestCard.svelte";
import type { SystemStats } from "$lib/systemStats";
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";
let manager: AnalysisManager = new AnalysisManager();
let loaded = $state(false);
let recording = $state(false);
let entries: ManifestEntry[] = $state([]);
let current_entry: ManifestEntry | undefined = $state(undefined);
let system_stats: SystemStats | undefined = $state(undefined);
$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;
recording = current_entry !== undefined;
system_stats = await get_system_stats();
loaded = true;
}, 1000);
return () => clearInterval(interval);
})
</script>
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center">
<img src="/rayhunter_text.png" class="h-10 xl:h-12"/>
<div class="flex flex-row gap-4">
<a class="flex flex-row gap-1 group" href="https://github.com/EFForg/rayhunter/issues" target="_blank">
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Report Issue</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 fill-rule="evenodd" d="M12.006 2a9.847 9.847 0 0 0-6.484 2.44 10.32 10.32 0 0 0-3.393 6.17 10.48 10.48 0 0 0 1.317 6.955 10.045 10.045 0 0 0 5.4 4.418c.504.095.683-.223.683-.494 0-.245-.01-1.052-.014-1.908-2.78.62-3.366-1.21-3.366-1.21a2.711 2.711 0 0 0-1.11-1.5c-.907-.637.07-.621.07-.621.317.044.62.163.885.346.266.183.487.426.647.71.135.253.318.476.538.655a2.079 2.079 0 0 0 2.37.196c.045-.52.27-1.006.635-1.37-2.219-.259-4.554-1.138-4.554-5.07a4.022 4.022 0 0 1 1.031-2.75 3.77 3.77 0 0 1 .096-2.713s.839-.275 2.749 1.05a9.26 9.26 0 0 1 5.004 0c1.906-1.325 2.74-1.05 2.74-1.05.37.858.406 1.828.101 2.713a4.017 4.017 0 0 1 1.029 2.75c0 3.939-2.339 4.805-4.564 5.058a2.471 2.471 0 0 1 .679 1.897c0 1.372-.012 2.477-.012 2.814 0 .272.18.592.687.492a10.05 10.05 0 0 0 5.388-4.421 10.473 10.473 0 0 0 1.313-6.948 10.32 10.32 0 0 0-3.39-6.165A9.847 9.847 0 0 0 12.007 2Z" clip-rule="evenodd"/>
</svg>
</a>
<a class="flex flex-row gap-1 group" href="https://efforg.github.io/rayhunter/" target="_blank">
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Docs</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="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 19V4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v13H7a2 2 0 0 0-2 2Zm0 0a2 2 0 0 0 2 2h12M9 3v14m7 0v4"/>
</svg>
</a>
</div>
</div>
<div class="m-4 xl:mx-8 flex flex-col gap-4">
{#if loaded}
<div class="flex flex-col lg:flex-row gap-4">
{#if recording}
<Card entry={current_entry} current={true} i={0} server_is_recording={recording}/>
{: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">
<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>
WARNING: Not Running
</span>
<span>Rayhunter is not currently running and will not detect abnormal behavior!</span>
<div class="flex flex-row justify-end mt-2">
<RecordingControls {recording} />
</div>
</div>
{/if}
<SystemStatsTable stats={system_stats!} />
</div>
<div class="flex flex-col gap-2">
<span class="text-xl">History</span>
<ManifestTable entries={entries} server_is_recording={recording} />
</div>
<DeleteAllButton/>
{:else}
<div class="flex flex-col justify-center items-center">
<img src="/rayhunter_orca_only.png" class="h-48 animate-spin"/>
<p class="text-xl">Loading...</p>
</div>
{/if}
</div>

BIN
bin/web/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

4
bin/web/static/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

15
bin/web/svelte.config.js Normal file
View File

@@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
// default options are shown. On some platforms
// these options are set automatically — see below
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true
})
}
};

View File

@@ -0,0 +1,17 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
'rayhunter-blue': '#4e4eb1',
'rayhunter-dark-blue': '#3f3da0',
'rayhunter-green': '#94ea18'
}
}
},
plugins: []
} as Config;

19
bin/web/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

30
bin/web/vite.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
configure: (proxy, _options) => {
proxy.on('error', (err, _req, _res) => {
console.log('proxy err:', err);
});
proxy.on('proxyReq', (proxyReq, req, _res) => {
console.log('Sending Request to the Target:', req.method, req.url);
});
proxy.on('proxyRes', (proxyRes, req, _res) => {
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
});
},
},
},
},
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});

5
book.toml Normal file
View File

@@ -0,0 +1,5 @@
[book]
authors = ["The Rayhunter Team"]
language = "en"
src = "doc"
title = "Rayhunter - An IMSI Catcher Catcher"

View File

@@ -1,9 +1,22 @@
# cat config.toml # cat config.toml
qmdl_store_path = "/data/rayhunter/qmdl" qmdl_store_path = "/data/rayhunter/qmdl"
port = 8080 port = 8080
debug_mode = false
enable_dummy_analyzer = false
colorblind_mode = false
# UI Levels: # UI Levels:
#
# Orbic and TP-Link with color display:
# 0 = invisible mode, no indicator that rayhunter is running # 0 = invisible mode, no indicator that rayhunter is running
# 1 = Subtle mode, display a green line at the top of the screen when rayhunter is running # 1 = Subtle mode, display a colored line at the top of the screen when rayhunter is running (green=running, white=paused, red=warnings)
# 2 = Demo Mode, display a fun orca gif # 2 = Demo Mode, display a fun orca gif
# 3 = display the EFF logo # 3 = display the EFF logo
#
# TP-Link with one-bit display:
# 0 = invisible mode
# 1..3 = show emoji for status. :) for running, ! for warnings, no mouth for paused.
ui_level = 1 ui_level = 1
# 0 = rayhunter does not read button presses
# 1 = double-tapping the power button starts/stops recordings
key_input_mode = 1

106
dist/install-common.sh vendored
View File

@@ -1,106 +0,0 @@
#!/usr/bin/env bash
install() {
if [[ -z "${SERIAL_PATH}" ]]; then
echo "\$SERIAL_PATH not set, did you run this from install-linux.sh or install-mac.sh?"
exit 1
fi
if [[ -z "${ADB}" ]]; then
echo "\$ADB not set, did you run this from install-linux.sh or install-mac.sh?"
exit 1
fi
force_debug_mode
setup_rootshell
setup_rayhunter
test_rayhunter
}
force_debug_mode() {
echo "Using adb at $ADB"
echo "Force a switch into the debug mode to enable ADB"
"$SERIAL_PATH" --root
echo -n "adb enabled, waiting for reboot..."
wait_for_adb_shell
echo " it's alive!"
echo -n "waiting for atfwd_daemon to startup..."
wait_for_atfwd_daemon
echo " done!"
}
wait_for_atfwd_daemon() {
until [ -n "$(_adb_shell 'pgrep atfwd_daemon')" ]
do
sleep 1
done
}
wait_for_adb_shell() {
until _adb_shell true 2> /dev/null
do
sleep 1
done
}
setup_rootshell() {
_adb_push rootshell /tmp/
_at_syscmd "cp /tmp/rootshell /bin/rootshell"
sleep 1
_at_syscmd "chown root /bin/rootshell"
sleep 1
_at_syscmd "chmod 4755 /bin/rootshell"
_adb_shell '/bin/rootshell -c id'
echo "we have root!"
}
_adb_push() {
"$ADB" push "$(dirname "$0")/$1" "$2"
}
_adb_shell() {
"$ADB" shell "$1"
}
_at_syscmd() {
"$SERIAL_PATH" "AT+SYSCMD=$1"
}
setup_rayhunter() {
_at_syscmd "mkdir -p /data/rayhunter"
_adb_push config.toml.example /data/rayhunter/config.toml
_adb_push rayhunter-daemon /data/rayhunter/
_adb_push scripts/rayhunter_daemon /tmp/rayhunter_daemon
_adb_push scripts/misc-daemon /tmp/misc-daemon
_at_syscmd "cp /tmp/rayhunter_daemon /etc/init.d/rayhunter_daemon"
_at_syscmd "cp /tmp/misc-daemon /etc/init.d/misc-daemon"
_at_syscmd "chmod 755 /etc/init.d/rayhunter_daemon"
_at_syscmd "chmod 755 /etc/init.d/misc-daemon"
echo -n "waiting for reboot..."
_at_syscmd reboot
# first wait for shutdown (it can take ~10s)
until ! _adb_shell true 2> /dev/null
do
sleep 1
done
# now wait for boot to finish
wait_for_adb_shell
echo " done!"
}
test_rayhunter() {
URL="http://localhost:8080"
"$ADB" forward tcp:8080 tcp:8080 > /dev/null
echo -n "checking for rayhunter server..."
SECONDS=0
while (( SECONDS < 30 )); do
if curl -L --fail-with-body "$URL" -o /dev/null -s; then
echo "success!"
echo "you can access rayhunter at $URL"
return
fi
sleep 1
done
echo "timeout reached! failed to reach rayhunter url $URL, something went wrong :("
}

17
dist/install-linux.sh vendored
View File

@@ -1,17 +0,0 @@
#!/bin/env bash
set -e
if ! command -v adb &> /dev/null; then
if [ ! -d ./platform-tools ] ; then
echo "adb not found, downloading local copy"
curl -O "https://dl.google.com/android/repository/platform-tools-latest-linux.zip"
unzip platform-tools-latest-linux.zip
fi
export ADB="./platform-tools/adb"
else
export ADB=`which adb`
fi
export SERIAL_PATH="./serial-ubuntu-latest/serial"
. "$(dirname "$0")"/install-common.sh
install

17
dist/install-mac.sh vendored
View File

@@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -e
if ! command -v adb &> /dev/null; then
if [ ! -d ./platform-tools ]; then
echo "adb not found, downloading local copy"
curl -O "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip"
unzip platform-tools-latest-darwin.zip
fi
export ADB="./platform-tools/adb"
else
export ADB=`which adb`
fi
export SERIAL_PATH="./serial-macos-latest/serial"
. "$(dirname "$0")"/install-common.sh
install

View File

@@ -1 +0,0 @@
ECHO TODO

View File

@@ -1,12 +1,14 @@
#! /bin/bash #! /bin/sh
set -e set -e
case "$1" in case "$1" in
start) start)
echo -n "Starting rayhunter: " echo -n "Starting rayhunter: "
# Below line may be replaced by the installer with device-specific startup commands, such as mounting the SD card.
#RAYHUNTER-PRESTART
start-stop-daemon -S -b --make-pidfile --pidfile /tmp/rayhunter.pid \ start-stop-daemon -S -b --make-pidfile --pidfile /tmp/rayhunter.pid \
--startas /bin/bash -- -c "RUST_LOG=info exec /data/rayhunter/rayhunter-daemon /data/rayhunter/config.toml > /data/rayhunter/rayhunter.log 2>&1" --startas /bin/sh -- -c "RUST_LOG=info exec /data/rayhunter/rayhunter-daemon /data/rayhunter/config.toml > /data/rayhunter/rayhunter.log 2>&1"
echo "done" echo "done"
;; ;;
stop) stop)

BIN
doc/Rayhunter_0.3.2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

17
doc/SUMMARY.md Normal file
View File

@@ -0,0 +1,17 @@
# Summary
[Introduction](./introduction.md)
- [Installation](./installation.md)
- [Installing from the latest release](./installing-from-release.md)
- [Installing from the latest release (Windows)](./installing-from-release-windows.md)
- [Installing from source](./installing-from-source.md)
- [Updating Rayhunter](./updating-rayhunter.md)
- [Uninstalling](./uninstalling.md)
- [Using Rayhunter](./using-rayhunter.md)
- [Rayhunter's heuristics](./heuristics.md)
- [How we analyze a capture](./analyzing-a-capture.md)
- [Supported devices](./supported-devices.md)
- [TP-Link M7350](./tplink-m7350.md)
- [Orbic RC400L](./orbic.md)
- [Support, feedback, and community](./support-feedback-community.md)
- [Frequently Asked Questions](./faq.md)

View File

@@ -0,0 +1,3 @@
# How we analyze a capture
TODO

20
doc/faq.md Normal file
View File

@@ -0,0 +1,20 @@
# Frequently Asked Questions
### 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!
<a name="red"></a>
### Help, Rayhunter's line is red! 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!
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).
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.

3
doc/heuristics.md Normal file
View File

@@ -0,0 +1,3 @@
# Heuristics
TODO

7
doc/installation.md Normal file
View File

@@ -0,0 +1,7 @@
# Installing Rayhunter
So, you've got one of the [supported devices](./supported-devices.md), and are ready to start catching IMSI catchers. You have two options for installing Rayhunter:
* [installing from a release (recommended)](./installing-from-release.md)
* [installing from a release on Windows](./installing-from-release-windows.md)
* [installing from source](./installing-from-source.md)

View File

@@ -0,0 +1,32 @@
# Installing from the latest release (Windows)
Windows support in Rayhunter's installer is a work-in-progress. Depending on the device, the installation instructions differ.
## 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](http://192.168.0.1).
2. Download the latest release (must be at least 0.3.0), and unpack the zipfile.
3. Open PowerShell or CMD in that extracted folder, and run the binary appropriate for your operating system: `./installer-windows-x86_64/installer tplink`
4. Follow the instructions on the screen, if there are any.
## Orbic
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
![Zadig](./zadig2.png)
1. Select 'RNDIS (Interface 0)'
![Zadig](./zadig.png)
1. Click 'install driver' and wait for it to finish.
2. Download the latest `rayhunter-vX.X.X.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases). The version you download will have numbers instead of X
3. Unzip `rayhunter-vX.X.X` .
1. Open a powershell terminal by pressing Win+R and typing `powershell` and hitting enter.
5. Type `cd ~\Downloads\rayhunter-v<x.x.x>\installer-windows-x86_64` (**Replace <x.x.x> with the rayhunter version you just unzipped**) and hit enter.
5. Run the install script: `.\installer.exe orbic` and hit enter.
- The device will restart multiple times over the next few minutes.
- You will know it is done when you see terminal output that says `checking for rayhunter server...success!`
6. Rayhunter should now be running! You can verify this by following the instructions below to [view the web UI](#usage-viewing-the-web-ui). You should also see a green line flash along the top of top the display on the device.

View File

@@ -0,0 +1,48 @@
# Installing from the latest release
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.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases)
2. Decompress the `rayhunter-vX.X.X.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.zip
cd ~/Downloads/rayhunter-vX.X.X
```
3. Turn on your device by holding the power button on the front.
* For the Orbic, connect the device using a USB-C cable.
* For TP-Link, connect to its network using either WiFi or USB Tethering.
4. Run the install script for your operating system:
First, enter the correct subfolder for your operating system:
- for Ubuntu on x64 arhitecture: `cd installer-ubuntu-24`
- for Ubuntu on ARM64 arhitecture: `cd installer-ubuntu-24-aarch64`
- for MacOS on Intel (old macbooks) architecture: `cd installer-macos-intel`
- for MacOS on ARM (M1/M2 etc.) achitecture: `cd installer-macos-arm`
- for Windows: `cd installer-windows-x86_64`
```bash
# On MacOS, you must first remove the quarantine bit
xattr -d com.apple.quarantine install
```
Then run the installer:
```bash
./installer orbic
# or: ./installer tplink
```
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). You should also see a green line flash along the top of top the display on the device.
## Troubleshooting
* 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.
./installer --help
./installer util --help

View File

@@ -0,0 +1,51 @@
# Installing from source
Building Rayhunter from source, either for development or because the install script doesn't work on your system, involves a number of external dependencies. Unless you need to do this, we recommend you use our [compiled builds](https://github.com/EFForg/rayhunter/releases).
* Install [nodejs/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), which is required to build Rayhunter's web UI
* Make sure to build the site with `cd bin/web && npm install && npm run build` before building Rayhunter. If you're working directly on the frontend, `npm run dev` will allow you to test a local frontend with hot-reloading (use `http://localhost:5173` instead of `http://localhost:8080`).
* Install ADB on your computer using the instructions above, and make sure it's in your terminal's PATH
* You can verify if ADB is in your PATH by running `which adb` in a terminal. If it prints the filepath to where ADB is installed, you're set! Otherwise, try following one of these guides:
* [linux](https://askubuntu.com/questions/652936/adding-android-sdk-platform-tools-to-path-downloaded-from-umake)
* [macOS](https://www.repeato.app/setting-up-adb-on-macos-a-step-by-step-guide/)
* [Windows](https://medium.com/@yadav-ajay/a-step-by-step-guide-to-setting-up-adb-path-on-windows-0b833faebf18)
* Install `curl` on your computer to run the install scripts. It is not needed to build binaries.
### Install Rust targets
[Install Rust the usual way](https://www.rust-lang.org/tools/install). Then,
- install the cross-compilation target for the device rayhunter will run on:
```sh
rustup target add armv7-unknown-linux-musleabihf
```
- install the statically compiled target for your host machine to build the binary installer `serial`.
```sh
# check which toolchain you have installed by default with
rustup show
# now install the correct variant for your host platform, one of:
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-unknown-linux-musl
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
rustup target add x86_64-pc-windows-gnu
```
Now you can root your device and install Rayhunter by running:
```sh
cargo build --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features orbic
cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware
cargo run --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`
* 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.example` to `/data/rayhunter/config.toml`
* 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!

13
doc/introduction.md Normal file
View File

@@ -0,0 +1,13 @@
![Rayhunter Logo - An Orca taking a bite out of a cellular signal bar](https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png)
# Rayhunter
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).
It's also designed to be as easy to install and use as possible, regardless of you 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.
**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!*

20
doc/orbic.md Normal file
View File

@@ -0,0 +1,20 @@
# Orbic RC400L
The Orbic RC400L is an inexpensive LTE modem primarily designed for the US marked, and the original device for which Rayhunter is developed.
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).
[Please check whether the Orbic works in your country](https://www.frequencycheck.com/countries/), and whether the Orbic RC400L supports the right frequency bands for your purpose before buying.
## Supported Bands
| Frequency | Band |
| ------- | ------------------ |
| 5G (wideband,midband,nationwide) | n260/n261, n77, n2/5/48/66 |
| 4G | 2/4/5/12/13/48/66 |
| Global & Roaming | n257/n78 |
| Wifi 2.4Ghz | b/g/n |
| Wifi 5Ghz | a/ac/ax |
| Wifi 6 | 🮱 |

Some files were not shown because too many files have changed in this diff Show More