562 Commits

Author SHA1 Message Date
oopsbagel
fe6afac817 Merge pull request #499 from EFForg/installer-issue-tmpl
Add an issue template for Installer issues
2025-08-02 18:50:13 +00:00
oopsbagel
8e708f145e doc/pinephone: the installer runs on the phone 2025-08-01 09:42:34 -07:00
oopsbagel
03c00a1f19 installer/orbic: warn windows users this may brick
The windows installer seems to sometimes brick the Orbic's ARM core,
resulting in the DSP returning "Qmi Send Message Fail" when sent AT
commands.

This commit adds a loud warning and confirmation dialog for Windows
users before installing.
2025-07-31 22:22:55 -07:00
oopsbagel
64842c7140 release v0.5.1 2025-07-31 22:22:55 -07:00
Markus Unterwaditzer
e108c21fc2 Use ./installer in docs
See https://github.com/EFForg/rayhunter/discussions/490
2025-07-31 20:55:41 +02:00
Sashanoraa
49a2108214 Add an issue template for Installer issues 2025-07-31 14:42:17 -04:00
Markus Unterwaditzer
53a6cbe95a Fix line endings on Windows
Fix #489
2025-07-31 18:06:52 +02:00
Sashanoraa
398997af67 Refactor diag thread to have full control over the QMDL store
Fixes #269. Refactor also pull diag thread logic out into state machine
object for better encapsulation and reuse.
2025-07-31 11:47:11 +02:00
oopsbagel
6b109a9d76 Merge pull request #498 from oopsbagel/wingtech-wifi-install-fix
wingtech: install without disabling wifi
2025-07-31 03:29:53 +00:00
oopsbagel
d9688b1796 wingtech: install without disabling wifi
Previously, the unlocking method for the wingtech hotspot would add a
invalid mac address to the blocklist. This would prevent the wifi from
coming online after rebooting until the invalid mac was removed.

This commit changes the unlocking method to attempt to *remove* an
invalid mac, creating a no-op condition that still works for unlocking
root access to the hotspot.

This commit also adds documentation for a problem where the hotspot
would occasionally not reboot while completely disconnected and
installing over wifi.

Fixes #466
2025-07-30 20:09:26 -07:00
Sashanoraa
7466c1c669 Fixes #381 UI no longer X overflows on mobile
Button will horizontally shrink a little on smaller screens and buttons
and tables will X scroll if needed.
2025-07-30 14:13:16 -04:00
Cooper Quintin
6a51050921 Update doc/configuration.md
Co-authored-by: Will Greenberg <ifnspifn@gmail.com>
2025-07-28 16:05:58 -07:00
Matej Kovacic
0935cf8239 Delete doc/Rayhunter_0.3.2.png 2025-07-28 16:05:58 -07:00
Matej Kovacic
d25e9588e2 Update heuristics.md 2025-07-28 16:05:58 -07:00
Matej Kovacic
a8ff95a07b Update using-rayhunter.md 2025-07-28 16:05:58 -07:00
Matej Kovacic
ac86277903 Add files via upload 2025-07-28 16:05:58 -07:00
Matej Kovacic
8e9abc718a Update configuration.md 2025-07-28 16:05:58 -07:00
Matej Kovacic
d92fb16c57 Add files via upload 2025-07-28 16:05:58 -07:00
Markus Unterwaditzer
f8824ce7e7 Remove blocking code and spawn_blocking
Rayhunter uses a mixture of spawn and spawn_blocking, then also does
some blocking operations inside of async code.

Move everything to async. This allows us to use the single-threaded
runtime.

Now the binary is 100kB smaller, and the memory usage also improved by
~100kB on tplink.
2025-07-28 16:04:24 -07:00
Sashanoraa
9694aa826b Use firmware-devel profile for make.sh 2025-07-28 11:09:48 -07:00
Sashanoraa
b859dde0c8 Add firmware-devel profile to cargo config
This speeds up compile time in exchange for binary size,
which is often a worthy trade-off in development when iteration speed
matters.
2025-07-28 11:09:48 -07:00
oopsbagel
5b6a73bc44 Merge pull request #477 from EFForg/release-0.5.0
Bump version to 0.5.0
2025-07-25 20:11:00 +00:00
Will Greenberg
8cbdbf5ebe Bump version to 0.5.0 2025-07-25 12:58:48 -07:00
Cooper Quintin
ccce63e90c address comments 2025-07-24 16:44:59 -07:00
Cooper Quintin
68b13ea09e Update lib/src/analysis/incomplete_sib.rs
Co-authored-by: Will Greenberg <willg@eff.org>
2025-07-24 16:44:59 -07:00
Cooper Quintin
672d825bdb rm stray char 2025-07-24 16:44:59 -07:00
Cooper Quintin
fd216ecb72 add incomplete sib heuristic 2025-07-24 16:44:59 -07:00
Cooper Quintin
07d43b5924 remove unused heuristic 2025-07-24 16:44:59 -07:00
Will Greenberg
bd3e439a1d lib: pycrate-rs now lives in EFF's github org 2025-07-24 15:19:33 -07:00
oopsbagel
5491c3f3a0 installer: remove struct NoArgs
An empty struct indicating no arguments is superfluous if the caller
doesn't specify a tuple in its corresponding match arm.
2025-07-24 09:48:41 -07:00
oopsbagel
fa14e4ecfc doc: add pinephone to supported devices 2025-07-24 09:48:41 -07:00
oopsbagel
8583064e46 installer: add pinephone and pinephonepro support 2025-07-24 09:48:41 -07:00
oopsbagel
d3bd8d9dfc feat: add headless display mode 2025-07-24 09:48:41 -07:00
oopsbagel
b16a351727 Merge pull request #471 from oopsbagel/framebuffer-usually-display-status-bar
generic_framebuffer: show status when ui_level!=0
2025-07-24 04:44:26 +00:00
oopsbagel
cd781fe8d8 Merge pull request #472 from oopsbagel/doc-orbic-disable-wifi
doc: add how to disable wifi entirely to the faq
2025-07-24 04:43:59 +00:00
oopsbagel
df00e00076 doc: add how to disable wifi entirely to the faq
Fixes #234

Co-authored-by: joelishness <joelishness@gmail.com>
2025-07-23 16:14:56 -07:00
oopsbagel
1a810cfb33 generic_framebuffer: show status when ui_level!=0
Unless ui_level = 0 (invisible mode), display the status bar on
framebuffer displays even when running in demo mode.

Fixes #445
2025-07-23 15:48:09 -07:00
Sashanoraa
b16b1af65e Fixes #433 do not stop recording when deleting a past entry 2025-07-20 18:07:24 -04:00
Cooper Quintin
a346449ec5 cargo fmt 2025-07-18 11:19:07 -07:00
Cooper Quintin
464740a1a7 fix another false positive 2025-07-18 11:19:07 -07:00
Cooper Quintin
e07b0b05e7 imsi requested version 2; remove most false positives 2025-07-17 18:43:07 -07:00
Cooper Quintin
578bc0d234 modifications to rayhunter-check 2025-07-17 18:43:07 -07:00
oopsbagel
751d504440 rename config.toml.example to config.toml.in
Since we now replace a value in this file, it's more of a template than
an example.
2025-07-17 16:06:09 -07:00
oopsbagel
29c944af45 diag_device: accept rayhunter::Device arg
Move daemon's config::Device to lib and toggle the extra tplink ioctl
based on Device::Tplink rather than passing a boolean.
2025-07-17 16:06:09 -07:00
oopsbagel
e239653a44 config: rename display to device
Because we toggle some ioctl settings based on this field, change the
name to better capture that we're selecting which device we want to load
settings for, not just the display module to load. This creates room for
future per-device settings without needing more config file fields.
2025-07-17 16:06:09 -07:00
oopsbagel
841bc7b015 ci: use unified rayhunter-daemon in main workflow 2025-07-17 16:06:09 -07:00
oopsbagel
22d927aa25 unified rayhunter-daemon binary for all devices
Replace per-device features with config "display" field with the value
set at install time.
2025-07-17 16:06:09 -07:00
Will Greenberg
5b59efa4c8 cargo fmt 2025-07-17 10:41:54 -07:00
Will Greenberg
f273d28728 daemon: fix recovery timestamp 2025-07-17 10:41:54 -07:00
Will Greenberg
f1e283b52c daemon: do a best-effort manifest recovery
Revises @cooperq's recovery a bit by preventing any panics, recovering
whichever files we can and skipping the ones we can't.
2025-07-17 10:41:54 -07:00
Will Greenberg
1011c4b123 daemon: don't track analysis file bytes written
We don't actually use this for anything
2025-07-17 10:41:54 -07:00
Cooper Quintin
5db24e4b21 recreate manifest entries if manifest is recreated due to error fixes #199 2025-07-17 10:41:54 -07:00
oopsbagel
a72e4b2234 docs: minor fixes for <links>, npm from daemon/web 2025-07-16 17:29:38 -07:00
oopsbagel
ca0151f656 chore: cargo fmt 2025-07-16 16:56:16 -07:00
oopsbagel
56930db130 doc(tmohs1): add bands 2, 4, 5
These were originally excluded because they weren't tested, as they are
subsets of bands 25, 26, and 66, but are worth enumerating.
2025-07-16 16:56:16 -07:00
oopsbagel
f018b8f662 ci: build rayhunter-daemon for tmobile 2025-07-16 16:56:16 -07:00
oopsbagel
7e0f12f1c5 doc(tmohs1): one must change the default password 2025-07-16 16:56:16 -07:00
oopsbagel
e32a6f5b2e fix(installer/tmobile): wait 200ms for telnet 2025-07-16 16:56:16 -07:00
oopsbagel
58618f3412 doc: support the Tmobile TMOHS1 hotspot 2025-07-16 16:56:16 -07:00
oopsbagel
003a8b280b chore: cargo fmt 2025-07-16 16:56:16 -07:00
oopsbagel
27bf20fbf4 fix(installer): de-duplicate common wingtech/tmohs
The Tmobile TMOHS1 is clearly based on the Wingtech CT2MHS01, and thus
the installation process looks very similar between them.
2025-07-16 16:56:16 -07:00
oopsbagel
b7636386fc feat: support Tmobile TMOHS1 hotspot
Add support for the Tmobile TMOHS1, a Wingtech CT2MHS01-based hotspot
with a Qualcomm mdm9607. The TMOHS1 has no screen, only 5 LEDs, two of
which are RGB.
2025-07-16 16:56:16 -07:00
Cooper Quintin
f23cc07652 Update doc/heuristics.md
Co-authored-by: Will Greenberg <willg@eff.org>
2025-07-16 15:25:11 -07:00
Cooper Quintin
f9b621bde9 Update lib/src/analysis/nas_null_cipher.rs
Co-authored-by: Will Greenberg <willg@eff.org>
2025-07-16 15:25:11 -07:00
Cooper Quintin
a4cb9454bd add version 2025-07-16 15:25:11 -07:00
Cooper Quintin
fbac464b46 add config option and documentation 2025-07-16 15:25:11 -07:00
Cooper Quintin
b923d9d5a6 cargo fmt 2025-07-16 15:25:11 -07:00
Cooper Quintin
790c0963cd add nas null cipher analyzer 2025-07-16 15:25:11 -07:00
Will Greenberg
32106ac0f4 daemon: rm vestigial dummy_analyzer remnants 2025-07-16 13:20:14 -07:00
Will Greenberg
1ce4d99c59 web: consider unversioned heuristics to be v0
This lets us cleanly differentiate old heuristics (which we know contain
some false positives) from our current set.
2025-07-16 13:20:14 -07:00
Will Greenberg
b055ddc670 web: more linting 2025-07-16 13:20:14 -07:00
Will Greenberg
09d4328dc2 web: lint fixes 2025-07-16 13:20:14 -07:00
Will Greenberg
1a4deb7524 appease clippy 2025-07-16 13:20:14 -07:00
Will Greenberg
0585e0f996 run cargo fmt 2025-07-16 13:20:14 -07:00
Will Greenberg
c783831e78 check: support pcaps
rayhunter-check will now analyze any PCAP files it finds in addition to
QMDL
2025-07-16 13:20:14 -07:00
Will Greenberg
3ddbaa07ca CI: fix paths to daemon workspace 2025-07-16 13:20:14 -07:00
Will Greenberg
83f246e9af lib: bump the analysis report version 2025-07-16 13:20:14 -07:00
Will Greenberg
0d96b4c103 frontend: bump sveltekit version
We're using a config setting that's only present in sveltekit v2.13
onwards
2025-07-16 13:20:14 -07:00
Will Greenberg
7cd8835cab frontend: handle both old and new analysis reports
Adds support for versioned analysis reports (and defaults to v1 for
reports with no version).
2025-07-16 13:20:14 -07:00
Will Greenberg
e81df18315 check: crawl input dir for QMDL, update to new analysis format 2025-07-16 13:20:14 -07:00
Will Greenberg
0915103ede Flattens analysis structure a bit
Instead of mirroring the QMDL container format exactly, let our analysis
files just be flat lists of packet analysis. Also removes the dummy
analyzer and adds version numbers to analysis reports and Analyzers
2025-07-16 13:20:14 -07:00
Will Greenberg
da18a1f9da Split bin dir into separate daemon and check dirs
This lets us manage their increasingly disparate dependencies separately
2025-07-16 13:20:14 -07:00
oopsbagel
5bb3dc9db5 Merge pull request #455 from oopsbagel/ci-release-one-zip-per-platform
ci: release one zip{,.sha256sum} per platform
2025-07-16 14:43:52 +00:00
oopsbagel
c2c6004f4e ci: release one zip{,.sha256sum} per platform 2025-07-16 00:22:49 -07:00
Markus Unterwaditzer
e320874854 Remove PartialEq, Clone, Serialize from LTE RRC parser
Compiling telecom-parser takes 2 minutes on my machine. After removing
those derives it takes 1:15. I suspect it's mostly serde though.
2025-07-15 15:21:02 -07:00
Markus Unterwaditzer
300215206c Inline all assets into index.html, compress it, saves 300kB 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
5e328b889b Fix eslint issues 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
97cbe62f42 Fix all issues in npm run check 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
27408dd64a Add web frontend linter to CI 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
e5c0e13d32 run prettier 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
41133ba793 Changes to web configs 2025-07-10 12:24:26 -07:00
Markus Unterwaditzer
0be2b02349 Define missing stop_recording/start_recording_classes
These variables were referenced but not actually defined. Define them
and make the button disabled when rayhunter is stopping/starting
2025-07-10 12:24:26 -07:00
oopsbagel
81eb3eac57 Merge pull request #440 from oopsbagel/tokio-rt-current-thread
installer: tokio runtime flavor current_thread
2025-07-09 23:33:16 +00:00
Markus Unterwaditzer
3247d35b7e Fix clippy lints
https://github.com/EFForg/rayhunter/pull/451 is failing because we got
auto-upgraded to a new clippy, which lints against more things
2025-07-10 01:17:58 +02:00
oopsbagel
355242fa71 Merge pull request #448 from oopsbagel/arm32-installer
ci: build installer and rayhunter-check for armv7, rename ubuntu -> linux in release bundles
2025-07-07 18:34:19 +00:00
oopsbagel
72d6c65f29 ci: use soft float target for armv7
Support more platforms by using a the soft float musl target for
aarch32/armv7/v8. The installer is not performance bound by floating
point operations.
2025-07-06 16:04:17 -07:00
oopsbagel
5e66c26e70 ci: build installer and rayhunter-check for armv7
Support installing on aarch32/armv7 linux systems.
2025-07-04 12:18:12 -07:00
Markus Unterwaditzer
b0d8307a14 Add send-file utilities for wingtech and tplink
Since we never turn on ADB, it's a bit cumbersome to send files to the
device.
2025-07-04 11:21:51 +02:00
oopsbagel
cf0875f2e3 Merge pull request #442 from oopsbagel/release-independent-zips
ci: release platform independent installer zips
2025-07-03 08:33:45 +00:00
oopsbagel
1c51e5ed6f ci: release platform independent installer zips
Release one zip file for each operating system / architecture
combination supported by the installer.
2025-07-03 01:03:29 -07:00
oopsbagel
3a393fc29f installer: tokio runtime flavor current_thread
Slightly reduce binary size by using the smaller 'rt' feature and the
current_thread runtime flavor in the installer, since there is no
benefit to true multithreading.
2025-07-01 22:07:47 -07:00
oopsbagel
b97421d220 Merge pull request #438 from oopsbagel/doc-supported-devices
documentation: Attempt to clarify supported devices
2025-06-29 19:23:50 +00:00
oopsbagel
1bf386d5b7 doc: supported devices: say "recommended region"
Individual device pages can go into detail about specific bands and
roaming. This page is primarily for steering users towards which device
to buy, and we only want to recommend devices that are affordable in
their region.
2025-06-29 12:16:07 -07:00
oopsbagel
8de4dcfd18 doc: add white background to map for dark mode 2025-06-29 12:14:54 -07:00
QuietEngineer
c0b1d4608a docs: standardize rayhunter casing
Use uppercase Rayhunter throughout docs when refering to the project and software.
Does not change pathnames.
2025-06-29 21:02:09 +02:00
QuietEngineer
ee8bf0107a docs: misc typo fixes 2025-06-29 21:02:09 +02:00
oopsbagel
664ffc8c75 doc: move wingtech developer details to the bottom 2025-06-29 02:07:22 -07:00
oopsbagel
d03debe67c doc: add region table, map for recommended devices
Implement reordering from untitaker's suggested changes.
2025-06-29 02:05:58 -07:00
oopsbagel
60922afc87 Merge pull request #436 from oopsbagel/smaller-installer
Smaller installer
2025-06-29 00:34:05 +00:00
oopsbagel
932fef32b9 Merge pull request #421 from EFForg/doc-recommended-devices
doc: Give guidance on which device to get
2025-06-29 00:33:50 +00:00
Markus Unterwaditzer
e259417f35 Revert some of the changes in supported-devices index 2025-06-29 02:07:13 +02:00
Markus Unterwaditzer
3889c89b5a Fix autolinks 2025-06-29 00:51:01 +02:00
Markus Unterwaditzer
bd074066c5 Adjust language around supported versions, to be similar across pages 2025-06-29 00:38:47 +02:00
Markus Unterwaditzer
8b44f604ea sort orbic to the top 2025-06-29 00:38:47 +02:00
Markus Unterwaditzer
ef7b8129ef Fix version number in docs, 0.3.5 does not exist 2025-06-29 00:38:47 +02:00
Markus Unterwaditzer
c3fd724ac1 doc: Give guidance on which device to get 2025-06-29 00:38:47 +02:00
oopsbagel
28ead37111 cargo/config: drop inherited firmware profile opts
These options are shared with the release profile.
2025-06-28 15:25:15 -07:00
oopsbagel
6efe83b36d cargo/config: build release bins with opt-level z
This yields a smaller binary and faster compile times than the default.

cf 5.6M binary in 2m 12s vs. 4.7M in 1m 39s on my machine.
2025-06-28 14:40:12 -07:00
oopsbagel
4d0427fe68 installer: fewer tokio and axum crate features 2025-06-28 05:26:59 -07:00
oopsbagel
1ee35dad71 cargo/config: build release binaries with fat lto
Reduce installer binary size with link-time optimisation.
2025-06-28 05:19:52 -07:00
oopsbagel
5d2a5a2577 Merge pull request #434 from oopsbagel/profile-release-strip-debuginfo
cargo/config: strip debuginfo from --release bins for a significantly smaller installer size
2025-06-28 11:00:28 +00:00
oopsbagel
a4f4e12a57 ci: build everything when .cargo changes 2025-06-28 01:06:56 -07:00
oopsbagel
55178e60fd cargo/config: strip debuginfo from release bins
rustc -C strip=debuginfo leaves the symbol table intact, meaning
RUST_BACKTRACE=1 on the installer still produces helpful output.

This significantly reduces the binary size, eg the amd64 installer goes
from 93M to 21M. Stripping the symbol table only reclaims a further ~2M.
2025-06-28 01:06:51 -07:00
Simon Fondrie-Teitler
5019f2a9d1 Bump Rust edition to 2024
Includes new cargo fmt changes
2025-06-28 00:13:15 +02:00
Simon Fondrie-Teitler
25978a4da4 Fix tplink clippy warnings 2025-06-27 23:23:27 +02:00
Simon Fondrie-Teitler
4ad79707bb Add two too_many_arguments exceptions 2025-06-27 23:23:27 +02:00
Simon Fondrie-Teitler
5f45ae31d8 Fail build on cargo check and clippy warnings 2025-06-27 23:23:27 +02:00
Simon Fondrie-Teitler
ed3072eb8e also run formatter 2025-06-27 23:23:27 +02:00
Simon Fondrie-Teitler
94289dcad5 Fix clippy complaints 2025-06-27 23:23:27 +02:00
Markus Unterwaditzer
2b86691e57 Bump hampi to 0.7.1 (#423)
It's not clear for how long this revision will exist upstream now that
the PR is (squash) merged
2025-06-25 09:59:39 +02:00
oopsbagel
0a15ca1b1a Merge pull request #420 from oopsbagel/wingtech-docs
doc: Wingtech CT2MHS01
2025-06-25 02:44:54 +00:00
oopsbagel
eeef42f4cb doc(wingtech): ./installer not ./install 2025-06-24 17:08:39 -07:00
oopsbagel
04cf0ab73a doc(Wingtech): device tiers, wt installation
Documents:
- supported device tiers
- wingtech installation
- wingtech shell unlocking
- wingtech rayhunter supported version
- links to wingtech fcc emissions lte test report
2025-06-24 16:23:04 -07:00
Cooper Quintin
23a0f72c2f cargo fmt 2025-06-24 12:52:33 -07:00
Will Greenberg
efae6203a9 rm unneeded comment 2025-06-24 12:52:33 -07:00
Will Greenberg
2e4de4a2df lib: Use pycrate-rs NAS parser 2025-06-24 12:52:33 -07:00
Will Greenberg
deeab1f1b0 Fix make script
Only build the daemon for make's purposes
2025-06-24 10:30:49 -07:00
Will Greenberg
83dba77cba Bump version to 0.4.0 2025-06-24 10:30:49 -07:00
Markus Unterwaditzer
542aff4fdf Merge pull request #351 from untitaker/restart
Config change and restart UI
2025-06-24 19:23:30 +02:00
oopsbagel
aac0c34eaa doc: Wingtech CT2MHS01 2025-06-24 01:10:50 -07:00
Markus Unterwaditzer
2ececf9c58 Fix tests 2025-06-24 02:09:22 +02:00
Markus Unterwaditzer
2cba26a4cc Remove unused import 2025-06-24 01:25:01 +02:00
Markus Unterwaditzer
48c0592b18 Merge remote-tracking branch 'origin/main' into restart 2025-06-24 01:24:43 +02:00
Markus Unterwaditzer
a21c9af354 Restart when config is set 2025-06-24 01:12:46 +02:00
Markus Unterwaditzer
0c241aba23 Address review comments 2025-06-23 14:30:40 -07:00
Markus Unterwaditzer
b2502847a1 Add ZIP download endpoint 2025-06-23 14:30:40 -07:00
Markus Unterwaditzer
be6f29dcf1 Add exponential backoff 2025-06-23 22:47:34 +02:00
Markus Unterwaditzer
2114206909 Remove advanced options 2025-06-23 21:24:04 +02:00
Markus Unterwaditzer
f735f033d3 Fix indentation 2025-06-23 21:13:41 +02:00
Markus Unterwaditzer
b825174a07 Apply suggestions from code review
Co-authored-by: Will Greenberg <ifnspifn@gmail.com>
2025-06-23 21:03:01 +02:00
Markus Unterwaditzer
29823d3e82 Update documentation and fix broken links
* Add a new configuration page and move content out from TP-Link. The
  Configuration section in TP-Link is duplicating what is already in
  config.toml.example, and given that we have recently added a lot of
  new options I don't want to maintain multiple copies.

* Lots of anchor links were broken since we moved docs from README into
  mdbook. Fix them all.

* Document that the key input feature is disabled since 0.4.0.

* Smaller cosmetic changes:

  * Make TP-Link M7350 page consistent with TP-Link M7310 page.
  * Fix indentation on some bullet points.
  * Center-align the rayhunter logo in introduction.md to calm my soul.
    It is still misaligned with the page title above itself.
  * Add "edit on github" link in mdbook settings.
2025-06-23 09:40:20 -07:00
Markus Unterwaditzer
e52d382514 Make SvelteKit build reproducible 2025-06-23 09:38:00 -07:00
Markus Unterwaditzer
a17e255148 un-skip jobs for testing 2025-06-23 09:38:00 -07:00
Markus Unterwaditzer
0f98b05475 Try out rust-cache action 2025-06-23 09:38:00 -07:00
Markus Unterwaditzer
5e5514a11f Fix key_input blocking shutdown
Discovered in #351 where restart would hang forever.

key_input.rs never properly implemented shutdown because it didn't have
to do anything interesting on shutdown.

Wire up oneshot channels so that it falls in line with other services.

I do wonder though if there's a more clever way of handling this. For
example I could just not use the task_tracker, use tokio::spawn and let
the task get cancelled by tokio.
2025-06-21 23:57:54 +02:00
Markus Unterwaditzer
9904b74d21 basic ui 2025-06-20 11:47:40 +02:00
Markus Unterwaditzer
d166dfc13d get config and set config 2025-06-20 11:47:40 +02:00
Markus Unterwaditzer
9b759e6b42 add basic restart endpoint 2025-06-20 11:47:40 +02:00
oopsbagel
5614c725a0 Merge pull request #418 from EFForg/tplink-hotfix-v9
Fix broken installer on TP-Link M7350 v9
2025-06-20 08:09:40 +00:00
oopsbagel
5a7fc2a063 Merge pull request #387 from oopsbagel/wingtech-ct2mhs01
feat: support Wingtech CT2MHS01 hotspot
2025-06-20 08:00:56 +00:00
Markus Unterwaditzer
e601320b3f Fix broken installer on TP-Link M7350 v9
TP-Link v9 was inadvertently broken via https://github.com/EFForg/rayhunter/pull/390
2025-06-20 02:01:55 +02:00
Cooper Quintin
0b05d1617c enable null cipher heuristic by default in config 2025-06-18 13:32:37 -07:00
Cooper Quintin
e7ba02173a update heuristic docs 2025-06-18 13:32:37 -07:00
Cooper Quintin
0b0dd4ed43 add file extensions to downloads, fixes #397 2025-06-18 13:32:37 -07:00
Markus Unterwaditzer
f2ff1be2ec Disable input keys by default
People say it's too easy to press, see #404
2025-06-18 08:44:26 -07:00
Markus Unterwaditzer
9f9adea5a1 Debounce key input events 2025-06-18 08:05:35 -07:00
Markus Unterwaditzer
cb2092d14f Fix last_message_time showing as 1970 when capture is 0 bytes
This is the same bug as #224, but regressed in the new UI
2025-06-17 16:38:49 -07:00
Cooper Quintin
76cdb3ecf1 re-add warning again 2025-06-17 16:30:22 -07:00
Cooper Quintin
bee5152381 re enable null cipher test 2025-06-17 16:30:22 -07:00
Cooper Quintin
2634271715 re-enable null cipher and update docs 2025-06-17 16:30:22 -07:00
Cooper Quintin
58913314aa helpful comment 2025-06-17 16:30:22 -07:00
Cooper Quintin
4f5bf4aa78 silence logging and point to specific revision and re-add warning 2025-06-17 16:30:22 -07:00
Cooper Quintin
bfc85c5103 re enable null cipher test 2025-06-17 16:30:22 -07:00
Cooper Quintin
7923327ba9 update binary name. Fixes #128 2025-06-17 16:30:22 -07:00
oopsbagel
3ff714972c fix(installer/wingtech): parse json login response
Use serde via reqwest to deserialize the login response from qcmap_auth
when obtaining an authentication token instead of golfing based on the
specific token length.
2025-06-16 00:38:28 -07:00
oopsbagel
a5d8e601d9 Merge pull request #399 from oopsbagel/fix-blame-ignore-rev-file
chore/fix: fix blame ignore rev file rev
2025-06-16 03:42:57 +00:00
oopsbagel
5272a99fb5 chore/fix: fix blame ignore rev file rev
Due to rebasing, this pointed to a nonexistent commit.
2025-06-15 20:40:50 -07:00
oopsbagel
5d61ad53b4 Merge pull request #394 from EFForg/readme-404
Fix broken CI badge in README
2025-06-16 03:32:22 +00:00
Markus Unterwaditzer
88ee4fc87e Fix broken CI badge in README 2025-06-14 17:13:30 +02:00
oopsbagel
8aadfc20f2 fix(installer/wingtech): remove duplicated struct 2025-06-13 22:00:20 -07:00
oopsbagel
a234df1e1e fix(installer/wingtech): only say ok if telnet connected 2025-06-13 21:39:12 -07:00
oopsbagel
de25008742 Merge pull request #392 from EFForg/ci-restore-main-workflow-call
fix(ci): restore deleted workflow_call trigger
2025-06-14 02:45:25 +00:00
oopsbagel
185da9cb36 fix(ci): explain why workflow_call: is needed 2025-06-13 19:42:38 -07:00
oopsbagel
c366eb9e4d fix(ci): restore deleted workflow_call trigger
This is required to call this workflow from another workflow, such as
the release.yml workflow.
2025-06-13 19:38:49 -07:00
oopsbagel
5dfbeaef64 Merge pull request #390 from EFForg/m7310
Add support for TP-Link M7310
2025-06-14 02:36:14 +00:00
oopsbagel
62e4c15eb5 Merge pull request #391 from EFForg/fix-workflow-main
Do not publish docs from any branch
2025-06-14 02:34:35 +00:00
Markus Unterwaditzer
02c98a8e8e Do not publish docs from any branch
Documentation was being pushed from an unrelated PR that I opened,
because that PR was created from a branch pushed to efforg instead of my
own fork.
2025-06-14 02:10:56 +02:00
Markus Unterwaditzer
6c02f56250 Add support for TP-Link M7310
The device is very similar to the M7350, and might as well just be
another hardware version.
2025-06-13 16:04:13 +02:00
oopsbagel
f56acdf89d fix(installer/wingtech): better Response variable 2025-06-12 12:46:08 -07:00
oopsbagel
cb6f79f67a ci: build wingtech 2025-06-12 01:11:01 -07:00
oopsbagel
95951c5c38 fix(display/mod.rs): remove "no device" check
This check is unnecessary, cargo will not build if fn update_ui is
undefined.
2025-06-12 01:04:29 -07:00
oopsbagel
241fb2789b fix(display/mod.rs): remove "many devices" check
This check is unnecessary, cargo will not build if multiple versions of
fn update_ui are defined.
2025-06-12 01:01:05 -07:00
oopsbagel
3a3adb055b fix(installer): wingtech adb error message 2025-06-12 00:45:52 -07:00
oopsbagel
3ae2636d9e chore(installer/build.rs): cargo clippy 2025-06-12 00:44:36 -07:00
oopsbagel
79b2628d2f chore: cargo fmt 2025-06-12 00:43:00 -07:00
oopsbagel
cba898daf6 feat(installer/util): telnet functions in util.rs
Add installer util wingtech-start-telnet command.
Add installer util wingtech-start-adb command.
2025-06-12 00:39:27 -07:00
oopsbagel
cb1df974e4 feat: support Wingtech CT2MHS01 hotspot
Add support for the Wingtech CT2MHS01 hotspot, a Qualcomm mdm9650-based
device with a screen available for US$15-35. This device is often used
as a base platform for while labeled versions like the T-Mobile TMOHS1.
AT&T branded versions of the hotspot seem to be the most abundant.

The device has a framebuffer-driven screen at /dev/fb0 that behaves
similarly to the Orbic RC400L, although the userspace program
`displaygui` refreshes the screen significantly more often than on the
Orbic. This causes the green line on the screen to subtly flicker and
only be displayed during some frames. Subsequent work to fully control
the display without removing the OEM interface is desired.
2025-06-11 18:06:50 -07:00
Markus Unterwaditzer
86e08f9a85 Allow enabling/disabling analyzers from config file (#382)
Co-authored-by: Will Greenberg <willg@eff.org>
2025-06-10 21:37:38 +02:00
Markus Unterwaditzer
fb2149f0c8 Loosen up issue templates
A lot of the bug report we receive are about the web UI or the installer
failing, and there things like capture date just don't matter. We could
create separate templates for these types of bugs, but I'd think it's
probably better to just have one textbox with a few "reminder" questions
that are all optional.

Feature request template I think doesn't have this issue.

Also allow the creation of blank issues, because some issues are more
related to CI or devenv and don't neatly fit in any category. Let's just
hope nobody abuses that?
2025-06-10 11:49:56 -07:00
oopsbagel
bf2b00ce47 doc(faq): send commands with installer util shell 2025-06-10 11:49:20 -07:00
oopsbagel
fd453900c2 doc(faq): format code correctly 2025-06-10 11:49:20 -07:00
oopsbagel
3d29c5f306 doc: add re-enabling USB tethering to the FAQ
Fixes issue #294
2025-06-10 11:49:20 -07:00
jrddupont
74623dea02 doc: Fix instructions for mac quarantine bit (#385) 2025-06-10 00:33:12 +02:00
Cooper Quintin
48e73a0a41 revert accidental futures upgrade 2025-06-06 12:44:39 -07:00
Cooper Quintin
a36863e002 bump version 2025-06-06 12:44:39 -07:00
oopsbagel
48aac0f0bb Merge pull request #380 from oopsbagel/fix-ci-env
ci: don't try to use env:
2025-06-06 19:33:50 +00:00
oopsbagel
5749c305c6 ci: don't try to use env:
I apparently don't know how this works.
2025-06-06 12:30:36 -07:00
oopsbagel
f53688086d ci: minor syntax and workflow order fixes 2025-06-06 12:21:24 -07:00
oopsbagel
bd2e0b4394 ci: release from actions, only test changed files
This commit introduces release automation triggered by button clicks in
Github Actions, guarded by a check on whether all the Cargo.toml files
contain the same version string.

On PRs, changes to documentation no longer trigger code tests.
Similarly, changes to code that don't update documentation do not
trigger documentation tests. Changes that fail at the `cargo check`
stage abort early to prevent lengthy CI builds of the installer and
firmware.

Commits on the `main` branch always run the full test suite regardless
of what changed.

Releases also run the full check, test, build and publish suite.
2025-06-06 12:21:24 -07:00
alli.sys
1eea086199 Merge pull request #375 from EFForg/eff-adb
Switch to the EFF org fork of adb_client
2025-06-05 21:28:06 -04:00
Sashanoraa
d36c1f10cd Switch to the EFF org fork of adb_client 2025-06-05 20:22:43 -04:00
Markus Unterwaditzer
8d8d2bd8ec Fix crash when deleting recordings
Due to a refactor in https://github.com/EFForg/rayhunter/pull/350, we
had to move more into the shared codepath of StopRecording. The todo!()
used to be unreachable when it was just in the stop-recording endpoint.

Fix #367
2025-06-05 09:28:31 -07:00
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
Cooper Quintin
a23df84848 workaround for root not being root permissions issue (#72)
* workaround for root not being root permissions issue

* update setup_rootshell()
2024-10-24 12:02:47 -07:00
Will Greenberg
4e862841b3 serial: use vendored rusb 2024-10-22 14:46:40 -07:00
Will Greenberg
2cc8404b13 rm unused windows build 2024-10-22 14:35:48 -07:00
Will Greenberg
35ae2962f2 fix build script 2024-10-22 14:35:48 -07:00
Will Greenberg
1134361cca fix build-release 2024-10-22 13:30:34 -07:00
Cooper Quintin
bec680f93d fix release script 2024-10-22 12:51:40 -07:00
Cooper Quintin
968af93b69 fix nits (#68) 2024-10-22 12:43:11 -07:00
Cooper Quintin
ee75326912 Fix macos install (#67)
* update shell path and some docs

* download ADB if not present

* big O not little o

* bugfix

* bugfix

* silence errors for macos developers

* Update dist/install-common.sh

Co-authored-by: Will Greenberg <willg@eff.org>

---------

Co-authored-by: Will Greenberg <willg@eff.org>
2024-10-22 12:21:27 -07:00
will
3b9a001e88 Update readme to ensure adb is in PATH 2024-10-18 11:49:47 -07:00
will
78d33b2cff serial: claim USB interface
Without doing this, it seems macOS can't use any interface endpoints.
Also disconnect any kernel extensions that're using the interface,
since that can cause issues on macOS as well.
2024-10-18 09:43:12 -07:00
Will Greenberg
6c237e884c lib: rm duplicate TestAnalyzer 2024-10-10 12:41:25 -07:00
Will Greenberg
f3e4091e1d daemon: fix some bugs in the frontend js 2024-10-08 14:58:46 -07:00
Will Greenberg
16f705f29c Add the test analyzer entirely via daemon flags
Also consolidate the duplicate AnalysisWriter implementation
2024-10-08 14:58:46 -07:00
Cooper Quintin
a6fce6d568 fix bug where warnings not displayed 2024-10-08 14:58:46 -07:00
Will Greenberg
fcac6fdf16 Fix broken yaml 2024-10-08 14:58:46 -07:00
Will Greenberg
df84faa1f9 On-demand analysis of past recordings
* rayhunter-daemon: API for triggering and reading analysis
* rayhunter-daemon: rename readonly mode to debug mode
* rayhunter-daemon: debug mode allows live-loading frontend files
* rayhunter-check: rework to handle directories
* rayhunter-check: better output
* CI: build rayhunter-check
2024-10-08 14:58:46 -07:00
Cooper Quintin
c59fb7c013 Fix UI and add more logging (#61)
* add some more logging

* WIP attempt to fix async on update_ui

* fix async for update_ui
2024-10-03 13:32:59 -07:00
Cooper Quintin
ca4f49b15f Framebuffer update (#60)
* first pass at changing the UI color based on state

* adding flag to qmdl metadata for when hueristic is triggered

* update style for web page to match UI and have color alert on heuristic trigger

* add test analyzer

* rename example_analyzer to test_analyzer

* refactor ui update to not depend on server

* refactor to pass around color instead of display state for framebuffer channel

* add debug feature flag for test analyzer

* remove warning status from qmdl manifest

* dont keep has warning around
2024-10-03 10:41:59 -07:00
Will Greenberg
861aaedd47 rayhunter-check improvements 2024-08-19 16:49:01 -07:00
Will Greenberg
f6681a3703 Merge pull request #54 from EFForg/mac-bugfix
bugfix for mac install process
2024-08-05 10:11:59 -07:00
Cooper Quintin
d6bc307a81 bugfix for mac install process 2024-08-05 10:10:41 -07:00
Will Greenberg
7cbb3369d8 serial: when rooting, don't panic if device is already rooted 2024-08-05 09:57:09 -07:00
Will Greenberg
cb3dbff54a install-common: wait for atfwd_daemon to startup
We can't successfully run any AT commands until it has.
2024-08-05 09:57:09 -07:00
Will Greenberg
65e1cd4967 serial: split out --root process, don't wait for reboot, update usage text
We need to wait for atfwd_daemon to startup before sending any AT
commands, and I can't think of a way to reliably do that within rust.
So, instead of trying to switch the device to command mode before
executing a command, require the user to run the "--root" step
beforehand, and then start executing AT commands.
2024-08-05 09:57:09 -07:00
Will Greenberg
d6fb54afb3 lib: rm unused imports 2024-08-05 09:57:09 -07:00
Will Greenberg
bc93c01890 bin: rm deprecated tempdir crate 2024-08-05 09:57:09 -07:00
182 changed files with 33549 additions and 9691 deletions

View File

@@ -1,3 +1,50 @@
[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]
linker = "arm-linux-gnueabihf-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.armv7-unknown-linux-musleabihf]
linker = "rust-lld"
rustflags = ["-C", "target-feature=+crt-static"]
[target.armv7-unknown-linux-musleabi]
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"]
[profile.release]
# keep line numbers in stack traces for non-firmware binaries
debug = "limited"
lto = "fat"
opt-level = "z"
strip = "debuginfo"
[profile.firmware-devel]
inherits = "release"
opt-level = "s"
lto = false
# optimizations to reduce the binary size of firmware binaries
[profile.firmware]
inherits = "release"
strip = true
codegen-units = 1
panic = "abort"
debug = false

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

@@ -0,0 +1,2 @@
9fe75ac961c57e508bf7488ce51d596750fa8d37
76ffdf6bada515c9a5f63a600e6f1502288c147a

9
.gitattributes vendored Normal file
View File

@@ -0,0 +1,9 @@
# Files that are distributed onto the Rayhunter device always have to have
# Unix-style line endings, even if the installer is built on Windows with
# autocrlf enabled.
# Using CRLF for the init scripts will make them fail to execute on TP-Link.
# See https://github.com/EFForg/rayhunter/issues/489
dist/config.toml.in eol=lf
dist/scripts/misc-daemon eol=lf
dist/scripts/rayhunter_daemon eol=lf

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

@@ -0,0 +1,19 @@
name: Bug Report
description: File a bug report.
labels: ["bug"]
body:
- type: textarea
attributes:
label: Bug Report Details
description: |
Please provide the following information, if applicable:
placeholder: |
• **Rayhunter Version**: (e.g., v0.2.6)
• **Capture Date**: (YYYY-MM-DD, e.g., 2025-05-01)
• **Capture Location**: (If comfortable disclosing, what region or country were you in? e.g., Washington State)
• **Device and Model**: (Device you installed Rayhunter on, e.g., Orbic RC400L)
• **What happened?**: (What steps did you take to get to your issue? Tell us what you see!)
• **Expected behavior**: (Rayhunter's behavior differed from what I expected because...)
• **Relevant log output**: (Rayhunter data captures - QMDL and PCAP logs - or error codes)
validations:
required: true

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

@@ -0,0 +1,8 @@
blank_issues_enabled: true
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.

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

@@ -0,0 +1,26 @@
name: Feature Request
description: Suggest a new feature or improvement to Rayhunter
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..."

View File

@@ -0,0 +1,47 @@
name: Installer Issue
description: File an bug related to an installer issue.
labels: ["bug", "installer"]
body:
- type: input
attributes:
label: Rayhunter Version
placeholder: 'v0.5.0'
validations:
required: true
- type: dropdown
attributes:
label: Device
description: |
What device are you trying to install Rayhunter on?
options:
- Orbic RC400L
- Tplink HW7350
- Tplink HW7310
- Tmobile TMOHS1
- Wingtech CT2MHS0
- Pinephone
- Other / I'm not sure
validations:
required: true
- type: dropdown
attributes:
label: Installer OS
description: What operating system are running the installer from
multiple: false
options:
- Linux
- macOS
- Windows
validations:
required: true
- type: textarea
attributes:
label: Describe the Issue
description: |
Please describe the issue you're having installing Rayhunter.
Include the logs outputed by the installer program. If the installer
is crashing, please try running the installer with `RUST_BACKTRACE=1`
environment variable set so we can see exactly where the installer is
crashing.
validations:
required: true

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

@@ -1,77 +0,0 @@
name: Build Release
on:
push:
branches: [main, "release-*"]
env:
CARGO_TERM_COLOR: always
jobs:
build_serial:
strategy:
matrix:
platform:
- os: ubuntu-latest
build_name: serial
- os: windows-latest
build_name: serial.exe
- os: macos-latest
build_name: serial
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
- name: Build serial
run: cargo build --bin serial --release
- uses: actions/upload-artifact@v4
with:
name: serial-${{ matrix.platform.os }}
path: ./target/release/${{ matrix.platform.build_name }}
if-no-files-found: error
build_rootshell_and_rayhunter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-gnueabihf
- 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)
run: cargo build --bin rootshell --target armv7-unknown-linux-gnueabihf --release
- uses: actions/upload-artifact@v4
with:
name: rootshell
path: target/armv7-unknown-linux-gnueabihf/release/rootshell
if-no-files-found: error
- name: Build rayhunter-daemon (arm32)
run: cargo build --bin rayhunter-daemon --target armv7-unknown-linux-gnueabihf --release
- uses: actions/upload-artifact@v4
with:
name: rayhunter-daemon
path: target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon
if-no-files-found: error
build_release_zip:
needs:
- build_serial
- build_rootshell_and_rayhunter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- name: Fix executable permissions on binaries
run: chmod +x serial-*/serial rayhunter-daemon/rayhunter-daemon
- name: Setup release directory
run: mv rayhunter-daemon/rayhunter-daemon rootshell/rootshell serial-* dist
- name: Archive release directory
run: tar -cvf release.tar -C dist .
# TODO: have this create a release directly
- name: Upload release
uses: actions/upload-artifact@v4
with:
name: release.tar
path: release.tar
if-no-files-found: error

View File

@@ -1,20 +0,0 @@
name: Check and Test
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
check_and_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check
run: cargo check --verbose
- name: Run tests
run: cargo test --verbose

352
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,352 @@
name: main
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_call: # required to call this workflow from another workflow like release.yml
env:
CARGO_TERM_COLOR: always
FILE_ROOTSHELL: ../../rootshell/rootshell
FILE_RAYHUNTER_DAEMON: ../../rayhunter-daemon/rayhunter-daemon
RUSTFLAGS: "-Dwarnings"
jobs:
files_changed:
name: Detect file changes
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
code_changed: ${{ steps.files_changed.outputs.code_count }}
daemon_changed: ${{ steps.files_changed.outputs.daemon_count }}
web_changed: ${{ steps.files_changed.outputs.web_count }}
docs_changed: ${{ steps.files_changed.outputs.docs_count }}
installer_changed: ${{ steps.files_changed.outputs.installer_count }}
rootshell_changed: ${{ steps.files_changed.outputs.rootshell_count }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: detect file changes
id: files_changed
run: |
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
# If we are on main, or if these workflow files are being changed, run everything
if [ ${{ github.ref }} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo
then
echo "building everything"
echo code_count=forced >> "$GITHUB_OUTPUT"
echo daemon_count=forced >> "$GITHUB_OUTPUT"
echo web_count=forced >> "$GITHUB_OUTPUT"
echo docs_count=forced >> "$GITHUB_OUTPUT"
echo installer_count=forced >> "$GITHUB_OUTPUT"
echo rootshell_count=forced >> "$GITHUB_OUTPUT"
else
echo "code_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon -e ^installer -e ^check -e ^lib -e ^rootshell -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
echo "daemon_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon -e ^lib -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
echo "web_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon/web | wc -l)" >> "$GITHUB_OUTPUT"
echo "docs_count=$(git diff --name-only $lcommit...HEAD | grep -e ^book.toml -e ^doc | wc -l)" >> "$GITHUB_OUTPUT"
echo "installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer | wc -l)" >> "$GITHUB_OUTPUT"
echo "rootshell_count=$(git diff --name-only $lcommit...HEAD | grep -e ^rootshell | wc -l)" >> "$GITHUB_OUTPUT"
fi
mdbook_test:
name: Test mdBook Documentation builds
needs: files_changed
if: needs.files_changed.outputs.docs_changed != '0'
runs-on: ubuntu-latest
permissions:
contents: read
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:
name: Publish mdBook to Github Pages
needs: mdbook_test
if: ${{ github.ref == 'refs/heads/main' }}
permissions:
pages: write
contents: write
id-token: write
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
check_and_test:
needs: files_changed
if: needs.files_changed.outputs.code_changed != '0'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --all --check
- name: Check
run: |
pushd daemon/web
npm install
npm run build
popd
NO_FIRMWARE_BIN=true cargo check --verbose
- name: Run tests
run: |
NO_FIRMWARE_BIN=true cargo test --verbose
- name: Run clippy
run: |
NO_FIRMWARE_BIN=true cargo clippy --verbose
test_web_frontend:
needs: files_changed
if: needs.files_changed.outputs.web_changed != '0'
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: daemon/web
steps:
- uses: actions/checkout@v4
- run: npm install
- run: npm run lint
- run: npm run check
- run: npm run test
windows_installer_check_and_test:
needs: files_changed
if: needs.files_changed.outputs.installer_changed != '0'
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
- name: cargo check
shell: bash
run: |
cd installer
NO_FIRMWARE_BIN=true cargo check --verbose
- name: cargo test
shell: bash
run: |
cd installer
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features
build_rayhunter_check:
if: needs.files_changed.outputs.daemon_changed != '0'
needs:
- check_and_test
- files_changed
permissions:
contents: read
packages: write
strategy:
matrix:
platform:
- name: linux-x64
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- name: linux-armv7
os: ubuntu-latest
target: armv7-unknown-linux-musleabi
- name: linux-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: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
- name: Build rayhunter-check
run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
name: rayhunter-check-${{ matrix.platform.name }}
path: target/${{ matrix.platform.target }}/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
build_rootshell:
if: needs.files_changed.outputs.rootshell_changed != '0'
needs:
- check_and_test
- files_changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- name: Build rootshell (armv7)
run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
- uses: actions/upload-artifact@v4
with:
name: rootshell
path: target/armv7-unknown-linux-musleabihf/firmware/rootshell
if-no-files-found: error
build_rayhunter:
if: needs.files_changed.outputs.daemon_changed != '0'
needs:
- check_and_test
- files_changed
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- name: Build rayhunter-daemon (armv7)
run: |
pushd daemon/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
- uses: actions/upload-artifact@v4
with:
name: rayhunter-daemon
path: target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon
if-no-files-found: error
build_rust_installer:
if: needs.files_changed.outputs.installer_changed != '0'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- files_changed
- windows_installer_check_and_test
strategy:
matrix:
platform:
- name: linux-x64
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- name: linux-armv7
os: ubuntu-latest
target: armv7-unknown-linux-musleabi
- name: linux-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 }}
- uses: Swatinem/rust-cache@v2
- run: cargo build --package installer --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:
permissions:
contents: read
packages: write
needs:
- build_rayhunter_check
- build_rootshell
- build_rayhunter
- build_rust_installer
runs-on: ubuntu-latest
strategy:
matrix:
platform:
- linux-x64
- linux-aarch64
- linux-armv7
- macos-intel
- macos-arm
- windows-x86_64
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- name: Fix executable permissions on binaries
run: chmod +x installer-*/installer rayhunter-check-*/rayhunter-check rayhunter-daemon/rayhunter-daemon
- name: Get Rayhunter version
id: get_version
run: echo "VERSION=$(grep '^version' daemon/Cargo.toml | head -n 1 | cut -d'"' -f2)" >> $GITHUB_ENV
- name: Setup versioned release directory
run: |
platform="${{ matrix.platform }}"
dest="rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}"
mkdir "$dest"
mv installer-$platform/installer* "$dest"/installer
cp -r rayhunter-daemon rootshell/rootshell dist/* installer/install.ps1 "$dest"/
zip -r "$dest.zip" "$dest"
sha256sum "$dest.zip" > "$dest.zip.sha256"
- name: Upload zip release and sha256
uses: actions/upload-artifact@v4
with:
name: rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}
path: |
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip.sha256
if-no-files-found: error

48
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
# To use: navigate on Github to Actions, select "Release rayhunter" on the left, click "Run workflow" > "Run workflow" on the right.
# https://github.com/EFForg/rayhunter/actions/workflows/release.yml
name: Release rayhunter
on:
workflow_dispatch:
env:
GH_TOKEN: ${{ github.token }}
jobs:
check_version_same:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Ensure all Cargo.toml files have the same version defined.
run: |
defined_versions=$(find lib check daemon installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l)
find lib check daemon installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \;
echo number of defined versions = $defined_versions
if [ $defined_versions != "1" ]
then
echo "all Cargo.toml files must have the same version defined"
exit 1
fi
main:
needs: check_version_same
permissions:
contents: write
id-token: write
packages: write
pages: write
uses: ./.github/workflows/main.yml
release:
runs-on: ubuntu-latest
needs: main
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- name: Create release
run: |
version=$(grep ^version lib/Cargo.toml | cut -d' ' -f3 | tr -d '"')
gh release create --generate-notes -t "Rayhunter v$version" "v$version" rayhunter-v${version}-*/rayhunter-v${version}*.zi*

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/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).

2566
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,10 @@
members = [
"lib",
"bin",
"serial",
"daemon",
"check",
"rootshell",
"telcom-parser",
"installer",
]
resolver = "2"

View File

@@ -1,92 +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
```
@@@@@@@ @@@@@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@@@@@ @@@@@@@@ @@@@@@@
@@! @@@ @@! @@@ @@! !@@ @@! @@@ @@! @@@ @@!@!@@@ @@! @@! @@! @@@
@!@!!@! @!@!@!@! !@!@! @!@!@!@! @!@ !@! @!@@!!@! @!! @!!!:! @!@!!@!
!!: :!! !!: !!! !!: !!: !!! !!: !!! !!: !!! !!: !!: !!: :!!
: : : : : : .: : : : :.:: : :: : : : :: ::: : : :
_ _ _ _ _ _ _ _
)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/main.yml/badge.svg)
O .
O ' '
o ' .
o .'
__________.-' '...___
.-' ### '''...__
/ a### ## ''--.._ ______
'. # ######## ' .-'
'-._ ..**********#### ___...---'''\ '
'-._ __________...---''' \ l
\ | apc '._|
\__;
```
![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.
**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
1. Install the Android Debug Bridge (ADB) on your computer (don't worry about instructions for installing it on a phone/device yet). 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).
2. Download the latest [rayhunter release bundle](https://github.com/EFForg/rayhunter/releases) and extract it (on Windows use 7zip).
3. Run the install script inside the bundle corresponding to your platform (`install-linux.sh`, `install-mac.sh`).
4. 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`.
## Development
* Install ADB on your computer using the instructions above.
### 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!*
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).

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,34 +0,0 @@
[package]
name = "rayhunter-daemon"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "rayhunter-daemon"
path = "src/daemon.rs"
[[bin]]
name = "rayhunter-check"
path = "src/check.rs"
[dependencies]
rayhunter = { path = "../lib" }
toml = "0.8.8"
serde = { version = "1.0.193", features = ["derive"] }
tokio = { version = "1.35.1", features = ["full"] }
axum = "0.7.3"
futures-core = "0.3.30"
thiserror = "1.0.52"
log = "0.4.20"
env_logger = "0.10.1"
tokio-util = { version = "0.7.10", features = ["rt"] }
futures-macro = "0.3.30"
include_dir = "0.7.3"
mime_guess = "2.0.4"
tempdir = "0.3.7"
chrono = { version = "0.4.31", features = ["serde"] }
tokio-stream = "0.1.14"
futures = "0.3.30"
clap = { version = "4.5.2", features = ["derive"] }
serde_json = "1.0.114"
image = "0.25.1"

View File

@@ -1,31 +0,0 @@
use std::{future, path::PathBuf, pin::pin};
use rayhunter::{analysis::analyzer::Harness, diag::DataType, qmdl::QmdlReader};
use tokio::fs::File;
use clap::Parser;
use futures::TryStreamExt;
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
#[arg(short, long)]
qmdl_path: PathBuf,
}
#[tokio::main]
async fn main() {
env_logger::init();
let args = Args::parse();
let mut harness = Harness::new_with_all_analyzers();
let qmdl_file = File::open(args.qmdl_path).await.expect("failed to open QMDL file");
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_stream = pin!(qmdl_reader.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
println!("{}\n", serde_json::to_string(&harness.get_metadata()).expect("failed to serialize report metadata"));
while let Some(container) = qmdl_stream.try_next().await.expect("failed getting QMDL container") {
let row = harness.analyze_qmdl_messages(container);
println!("{}\n", serde_json::to_string(&row).expect("failed to serialize row"));
}
}

View File

@@ -1,58 +0,0 @@
use crate::error::RayhunterError;
use serde::Deserialize;
#[derive(Deserialize)]
struct ConfigFile {
qmdl_store_path: Option<String>,
port: Option<u16>,
readonly_mode: Option<bool>,
ui_level: Option<u8>,
}
#[derive(Debug)]
pub struct Config {
pub qmdl_store_path: String,
pub port: u16,
pub readonly_mode: bool,
pub ui_level: u8,
}
impl Default for Config {
fn default() -> Self {
Config {
qmdl_store_path: "/data/rayhunter/qmdl".to_string(),
port: 8080,
readonly_mode: false,
ui_level: 1,
}
}
}
pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError> where P: AsRef<std::path::Path> {
let mut config = Config::default();
if let Ok(config_file) = std::fs::read_to_string(&path) {
let parsed_config: ConfigFile = toml::from_str(&config_file)
.map_err(RayhunterError::ConfigFileParsingError)?;
if let Some(path) = parsed_config.qmdl_store_path { config.qmdl_store_path = path }
if let Some(port) = parsed_config.port { config.port = port }
if let Some(readonly_mode) = parsed_config.readonly_mode { config.readonly_mode = readonly_mode }
if let Some(ui_level) = parsed_config.ui_level { config.ui_level = ui_level }
}
Ok(config)
}
pub struct Args {
pub config_path: String,
}
pub fn parse_args() -> Args {
let args: Vec<String> = std::env::args().collect();
if args.len() != 2 {
println!("Usage: {} /path/to/config/file", args[0]);
std::process::exit(1);
}
Args {
config_path: args[1].clone(),
}
}

View File

@@ -1,207 +0,0 @@
mod config;
mod error;
mod pcap;
mod server;
mod stats;
mod qmdl_store;
mod diag;
mod framebuffer;
use crate::config::{parse_config, parse_args};
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::framebuffer::Framebuffer;
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::Router;
use stats::get_qmdl_manifest;
use tokio::sync::mpsc::{self, Sender};
use tokio::sync::oneshot::error::TryRecvError;
use tokio::task::JoinHandle;
use tokio_util::task::TaskTracker;
use std::net::SocketAddr;
use std::thread::sleep;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::{RwLock, oneshot};
use std::sync::Arc;
use include_dir::{include_dir, Dir};
// 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
// (i.e. user hit ctrl+c)
async fn run_server(
task_tracker: &TaskTracker,
config: &config::Config,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
server_shutdown_rx: oneshot::Receiver<()>,
diag_device_sender: Sender<DiagDeviceCtrlMessage>
) -> JoinHandle<()> {
let state = Arc::new(ServerState {
qmdl_store_lock,
diag_device_ctrl_sender: diag_device_sender,
readonly_mode: config.readonly_mode
});
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", get(get_analysis_report))
.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 listener = TcpListener::bind(&addr).await.unwrap();
task_tracker.spawn(async move {
info!("The orca is hunting for stingrays...");
axum::serve(listener, app)
.with_graceful_shutdown(server_shutdown_signal(server_shutdown_rx))
.await.unwrap();
})
}
async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
server_shutdown_rx.await.unwrap();
info!("Server received shutdown signal, exiting...");
}
// Loads a QmdlStore if one exists, and if not, only create one if we're not in
// readonly mode.
async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
match (RecordingStore::exists(&config.qmdl_store_path).await?, config.readonly_mode) {
(true, _) => Ok(RecordingStore::load(&config.qmdl_store_path).await?),
(false, false) => Ok(RecordingStore::create(&config.qmdl_store_path).await?),
(false, true) => Err(RayhunterError::NoStoreReadonlyMode(config.qmdl_store_path.clone())),
}
}
// Start a thread that'll track when user hits ctrl+c. When that happens,
// trigger various cleanup tasks, including sending signals to other threads to
// shutdown
fn run_ctrl_c_thread(
task_tracker: &TaskTracker,
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
server_shutdown_tx: oneshot::Sender<()>,
ui_shutdown_tx: oneshot::Sender<()>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>
) -> JoinHandle<Result<(), RayhunterError>> {
task_tracker.spawn(async move {
match tokio::signal::ctrl_c().await {
Ok(()) => {
let mut qmdl_store = qmdl_store_lock.write().await;
if qmdl_store.current_entry.is_some() {
info!("Closing current QMDL entry...");
qmdl_store.close_current_entry().await?;
info!("Done!");
}
server_shutdown_tx.send(())
.expect("couldn't send server shutdown signal");
info!("sending UI shutdown");
ui_shutdown_tx.send(())
.expect("couldn't send ui shutdown signal");
diag_device_sender.send(DiagDeviceCtrlMessage::Exit).await
.expect("couldn't send Exit message to diag thread");
},
Err(err) => {
error!("Unable to listen for shutdown signal: {}", err);
}
}
Ok(())
})
}
async fn update_ui(task_tracker: &TaskTracker, config: &config::Config, mut ui_shutdown_rx: oneshot::Receiver<()>){
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.");
}
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 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(framebuffer::Color565::Green, 2);
},
};
sleep(Duration::from_millis(100));
}
}).await.unwrap();
}
#[tokio::main]
async fn main() -> Result<(), RayhunterError> {
env_logger::init();
let args = parse_args();
let config = parse_config(&args.config_path)?;
// TaskTrackers give us an interface to spawn tokio threads, and then
// eventually await all of them ending
let task_tracker = TaskTracker::new();
let qmdl_store_lock = Arc::new(RwLock::new(init_qmdl_store(&config).await?));
let (tx, rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
if !config.readonly_mode {
let mut dev = DiagDevice::new().await
.map_err(RayhunterError::DiagInitError)?;
dev.config_logs().await
.map_err(RayhunterError::DiagInitError)?;
run_diag_read_thread(&task_tracker, dev, rx, qmdl_store_lock.clone());
}
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
run_ctrl_c_thread(&task_tracker, tx.clone(), server_shutdown_tx, ui_shutdown_tx, qmdl_store_lock.clone());
run_server(&task_tracker, &config, qmdl_store_lock.clone(), server_shutdown_rx, tx).await;
update_ui(&task_tracker, &config, ui_shutdown_rx).await;
task_tracker.close();
task_tracker.wait().await;
Ok(())
}

View File

@@ -1,205 +0,0 @@
use std::pin::pin;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::State;
use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use rayhunter::analysis::analyzer::Harness;
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::diag_device::DiagDevice;
use serde::Serialize;
use tokio::sync::RwLock;
use tokio::sync::mpsc::Receiver;
use rayhunter::qmdl::QmdlWriter;
use log::{debug, error, info};
use tokio::fs::File;
use tokio::io::{BufWriter, AsyncWriteExt};
use tokio_util::io::ReaderStream;
use tokio_util::task::TaskTracker;
use futures::{StreamExt, TryStreamExt};
use crate::qmdl_store::RecordingStore;
use crate::server::ServerState;
pub enum DiagDeviceCtrlMessage {
StopRecording,
StartRecording((QmdlWriter<File>, File)),
Exit,
}
struct AnalysisWriter {
writer: BufWriter<File>,
harness: Harness,
bytes_written: usize,
}
// We write our analysis results to a file immediately to minimize the amount of
// state Rayhunter has to keep track of in memory. The analysis file's format is
// Newline Delimited JSON
// (https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson), which
// lets us simply append new rows to the end without parsing the entire JSON
// object beforehand.
impl AnalysisWriter {
pub async fn new(file: File) -> Result<Self, std::io::Error> {
let mut result = Self {
writer: BufWriter::new(file),
harness: Harness::new_with_all_analyzers(),
bytes_written: 0,
};
let metadata = result.harness.get_metadata();
result.write(&metadata).await?;
Ok(result)
}
// Runs the analysis harness on the given container, serializing the results
// to the analysis file and returning the file's new length.
pub async fn analyze(&mut self, container: MessagesContainer) -> Result<usize, std::io::Error> {
let row = self.harness.analyze_qmdl_messages(container);
if !row.is_empty() {
self.write(&row).await?;
}
Ok(self.bytes_written)
}
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
let mut value_str = serde_json::to_string(value).unwrap();
value_str.push('\n');
self.bytes_written += value_str.len();
self.writer.write_all(value_str.as_bytes()).await?;
self.writer.flush().await?;
Ok(())
}
// Flushes any pending I/O to disk before dropping the writer
pub async fn close(mut self) -> Result<(), std::io::Error> {
self.writer.flush().await?;
Ok(())
}
}
pub fn run_diag_read_thread(
task_tracker: &TaskTracker,
mut dev: DiagDevice,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>
) {
task_tracker.spawn(async move {
let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry");
let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file));
let mut diag_stream = pin!(dev.as_stream().into_stream());
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file).await
.expect("failed to create analysis writer"));
loop {
tokio::select! {
msg = qmdl_file_rx.recv() => {
match msg {
Some(DiagDeviceCtrlMessage::StartRecording((new_writer, new_analysis_file))) => {
maybe_qmdl_writer = Some(new_writer);
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file).await
.expect("failed to write to analysis file"));
},
Some(DiagDeviceCtrlMessage::StopRecording) => {
maybe_qmdl_writer = None;
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
maybe_analysis_writer = None;
},
// None means all the Senders have been dropped, so it's
// time to go
Some(DiagDeviceCtrlMessage::Exit) | None => {
info!("Diag reader thread exiting...");
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
return Ok(())
},
}
}
maybe_container = diag_stream.next() => {
match maybe_container.unwrap() {
Ok(container) => {
if container.data_type != DataType::UserSpace {
debug!("skipping non-userspace diag messages...");
continue;
}
// keep track of how many bytes were written to the QMDL file so we can read
// a valid block of data from it in the HTTP server
if let Some(qmdl_writer) = maybe_qmdl_writer.as_mut() {
qmdl_writer.write_container(&container).await.expect("failed to write to QMDL writer");
debug!("total QMDL bytes written: {}, updating manifest...", qmdl_writer.total_written);
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???");
qmdl_store.update_entry_qmdl_size(index, qmdl_writer.total_written).await
.expect("failed to update qmdl file size");
debug!("done!");
} else {
debug!("no qmdl_writer set, continuing...");
}
if let Some(analysis_writer) = maybe_analysis_writer.as_mut() {
let analysis_file_len = analysis_writer.analyze(container).await
.expect("failed to analyze container");
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???");
qmdl_store.update_entry_analysis_size(index, analysis_file_len as usize).await
.expect("failed to update analysis file size");
}
},
Err(err) => {
error!("error reading diag device: {}", err);
return Err(err);
}
}
}
}
}
});
}
pub async fn start_recording(State(state): State<Arc<ServerState>>) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.readonly_mode {
return Err((StatusCode::FORBIDDEN, "server is in readonly mode".to_string()));
}
let mut qmdl_store = state.qmdl_store_lock.write().await;
let (qmdl_file, analysis_file) = qmdl_store.new_entry().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't create new qmdl entry: {}", e)))?;
let qmdl_writer = QmdlWriter::new(qmdl_file);
state.diag_device_ctrl_sender.send(DiagDeviceCtrlMessage::StartRecording((qmdl_writer, analysis_file))).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 stop_recording(State(state): State<Arc<ServerState>>) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.readonly_mode {
return Err((StatusCode::FORBIDDEN, "server is in readonly mode".to_string()));
}
let mut qmdl_store = state.qmdl_store_lock.write().await;
qmdl_store.close_current_entry().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't close current qmdl entry: {}", e)))?;
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 get_analysis_report(State(state): State<Arc<ServerState>>) -> Result<Response, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await;
let Some(entry) = qmdl_store.get_current_entry() else {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string()
));
};
let analysis_file = qmdl_store.open_entry_analysis(entry).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
let analysis_stream = ReaderStream::new(analysis_file);
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
let body = Body::from_stream(analysis_stream);
Ok((headers, body).into_response())
}

View File

@@ -1,92 +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)]
pub enum Color565 {
Red = 0b1111100000000000,
Green = 0b0000011111100000,
Blue = 0b0000000000011111,
White = 0b1111111111111111,
Black = 0b0000000000000000,
Cyan = 0b0000011111111111,
Yellow = 0b1111111111100000,
Pink = 0b1111010010011111,
}
#[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();
}
}

View File

@@ -1,66 +0,0 @@
use crate::ServerState;
use rayhunter::diag::DataType;
use rayhunter::gsmtap_parser;
use rayhunter::pcap::GsmtapPcapWriter;
use rayhunter::qmdl::QmdlReader;
use axum::body::Body;
use axum::http::header::CONTENT_TYPE;
use axum::extract::{State, Path};
use axum::http::StatusCode;
use axum::response::{Response, IntoResponse};
use tokio::io::duplex;
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
// 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.
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 entry = qmdl_store.entry_for_name(&qmdl_name)
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?;
if entry.qmdl_size_bytes == 0 {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"QMDL file is empty, try again in a bit!".to_string()
));
}
let qmdl_file = qmdl_store.open_entry_qmdl(&entry).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
// the QMDL reader should stop at the last successfully written data chunk
// (entry.size_bytes)
let (reader, writer) = duplex(1024);
let mut pcap_writer = GsmtapPcapWriter::new(writer).await.unwrap();
pcap_writer.write_iface_header().await.unwrap();
tokio::spawn(async move {
let mut reader = QmdlReader::new(qmdl_file, Some(entry.qmdl_size_bytes));
let mut messages_stream = pin!(reader.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
while let Some(container) = messages_stream.try_next().await.expect("failed getting QMDL container") {
for maybe_msg in container.into_messages() {
match maybe_msg {
Ok(msg) => {
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)
.expect("error parsing gsmtap message");
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
pcap_writer.write_gsmtap_message(gsmtap_msg, timestamp).await
.expect("error writing pcap packet");
}
},
Err(e) => error!("error parsing message: {:?}", e),
}
}
}
});
let headers = [(CONTENT_TYPE, "application/vnd.tcpdump.pcap")];
let body = Body::from_stream(ReaderStream::new(reader));
Ok((headers, body).into_response())
}

View File

@@ -1,249 +0,0 @@
use std::path::{PathBuf, Path};
use thiserror::Error;
use tokio::{fs::{self, File, try_exists}, io::AsyncWriteExt};
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Local};
#[derive(Debug, Error)]
pub enum RecordingStoreError {
#[error("Can't close an entry when there's no current entry")]
NoCurrentEntry,
#[error("Couldn't create file: {0}")]
CreateFileError(tokio::io::Error),
#[error("Couldn't read file: {0}")]
ReadFileError(tokio::io::Error),
#[error("Couldn't open directory at path: {0}")]
OpenDirError(tokio::io::Error),
#[error("Couldn't read manifest file: {0}")]
ReadManifestError(tokio::io::Error),
#[error("Couldn't write manifest file: {0}")]
WriteManifestError(tokio::io::Error),
#[error("Couldn't parse QMDL store manifest file: {0}")]
ParseManifestError(toml::de::Error)
}
pub struct RecordingStore {
pub path: PathBuf,
pub manifest: Manifest,
pub current_entry: Option<usize>, // index into manifest
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
pub struct Manifest {
pub entries: Vec<ManifestEntry>,
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
pub struct ManifestEntry {
pub name: String,
pub start_time: DateTime<Local>,
pub last_message_time: Option<DateTime<Local>>,
pub qmdl_size_bytes: usize,
pub analysis_size_bytes: usize,
}
impl ManifestEntry {
fn new() -> Self {
let now = Local::now();
ManifestEntry {
name: format!("{}", now.timestamp()),
start_time: now,
last_message_time: None,
qmdl_size_bytes: 0,
analysis_size_bytes: 0,
}
}
pub fn get_qmdl_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
let mut filepath = path.as_ref().join(&self.name);
filepath.set_extension("qmdl");
filepath
}
pub fn get_analysis_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
let mut filepath = path.as_ref().join(&self.name);
filepath.set_extension("ndjson");
filepath
}
}
impl RecordingStore {
// Returns whether a directory with a "manifest.toml" exists at the given
// path (though doesn't check if that manifest is valid)
pub async fn exists<P>(path: P) -> Result<bool, RecordingStoreError> where P: AsRef<Path> {
let manifest_path = path.as_ref().join("manifest.toml");
let dir_exists = try_exists(path).await.map_err(RecordingStoreError::OpenDirError)?;
let manifest_exists = try_exists(manifest_path).await.map_err(RecordingStoreError::ReadManifestError)?;
Ok(dir_exists && manifest_exists)
}
// Loads an existing RecordingStore at the given path. Errors if no store exists,
// or if it's malformed.
pub async fn load<P>(path: P) -> Result<Self, RecordingStoreError> where P: AsRef<Path> {
let path: PathBuf = path.as_ref().to_path_buf();
let manifest = RecordingStore::read_manifest(&path).await?;
Ok(RecordingStore {
path,
manifest,
current_entry: None,
})
}
// Creates a new RecordingStore at the given path. This involves creating a dir
// and writing an empty manifest.
pub async fn create<P>(path: P) -> Result<Self, RecordingStoreError> where P: AsRef<Path> {
let manifest_path = path.as_ref().join("manifest.toml");
fs::create_dir_all(&path).await
.map_err(RecordingStoreError::OpenDirError)?;
let mut manifest_file = File::create(&manifest_path).await
.map_err(RecordingStoreError::WriteManifestError)?;
let empty_manifest = Manifest { entries: Vec::new() };
let empty_manifest_contents = toml::to_string_pretty(&empty_manifest)
.expect("failed to serialize manifest");
manifest_file.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> where P: AsRef<Path> {
let manifest_path = path.as_ref().join("manifest.toml");
let file_contents = fs::read_to_string(&manifest_path).await
.map_err(RecordingStoreError::ReadManifestError)?;
toml::from_str(&file_contents)
.map_err(RecordingStoreError::ParseManifestError)
}
// Closes the current entry (if needed), creates a new entry based on the
// current time, and updates the manifest. Returns a tuple of the entry's
// newly created QMDL file and analysis file.
pub async fn new_entry(&mut self) -> Result<(File, File), RecordingStoreError> {
// if we've already got an entry open, close it
if self.current_entry.is_some() {
self.close_current_entry().await?;
}
let new_entry = ManifestEntry::new();
let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path);
let qmdl_file = File::options()
.create(true)
.write(true)
.open(&qmdl_filepath).await
.map_err(RecordingStoreError::CreateFileError)?;
let analysis_filepath = new_entry.get_analysis_filepath(&self.path);
let analysis_file = File::options()
.create(true)
.write(true)
.open(&analysis_filepath).await
.map_err(RecordingStoreError::CreateFileError)?;
self.manifest.entries.push(new_entry);
self.current_entry = Some(self.manifest.entries.len() - 1);
self.write_manifest().await?;
Ok((qmdl_file, analysis_file))
}
// Returns the corresponding QMDL file for a given entry
pub async fn open_entry_qmdl(&self, entry: &ManifestEntry) -> Result<File, RecordingStoreError> {
File::open(entry.get_qmdl_filepath(&self.path)).await
.map_err(RecordingStoreError::ReadFileError)
}
// Returns the corresponding QMDL file for a given entry
pub async fn open_entry_analysis(&self, entry: &ManifestEntry) -> Result<File, RecordingStoreError> {
File::open(entry.get_analysis_filepath(&self.path)).await
.map_err(RecordingStoreError::ReadFileError)
}
// Unsets the current entry
pub async fn close_current_entry(&mut self) -> Result<(), RecordingStoreError> {
match self.current_entry {
Some(_) => {
self.current_entry = None;
Ok(())
},
None => Err(RecordingStoreError::NoCurrentEntry)
}
}
// Sets the given entry's size and updates the last_message_time to now, updating the manifest
pub async fn update_entry_qmdl_size(&mut self, entry_index: usize, size_bytes: usize) -> Result<(), RecordingStoreError> {
self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes;
self.manifest.entries[entry_index].last_message_time = Some(Local::now());
self.write_manifest().await
}
// Sets the given entry's analysis file size
pub async fn update_entry_analysis_size(&mut self, entry_index: usize, size_bytes: usize) -> Result<(), RecordingStoreError> {
self.manifest.entries[entry_index].analysis_size_bytes = size_bytes;
self.write_manifest().await
}
async fn write_manifest(&mut self) -> Result<(), RecordingStoreError> {
let mut manifest_file = File::options()
.write(true)
.open(self.path.join("manifest.toml")).await
.map_err(RecordingStoreError::WriteManifestError)?;
let manifest_contents = toml::to_string_pretty(&self.manifest)
.expect("failed to serialize manifest");
manifest_file.write_all(manifest_contents.as_bytes()).await
.map_err(RecordingStoreError::WriteManifestError)?;
Ok(())
}
// Finds an entry by filename
pub fn entry_for_name(&self, name: &str) -> Option<ManifestEntry> {
self.manifest.entries.iter()
.find(|entry| entry.name == name)
.cloned()
}
pub fn get_current_entry(&self) -> Option<&ManifestEntry> {
let entry_index = self.current_entry?;
self.manifest.entries.get(entry_index)
}
}
#[cfg(test)]
mod tests {
use tempdir::TempDir;
use super::*;
#[tokio::test]
async fn test_load_from_empty_dir() {
let dir = TempDir::new("qmdl_store_test").unwrap();
assert!(!RecordingStore::exists(dir.path()).await.unwrap());
let _created_store = RecordingStore::create(dir.path()).await.unwrap();
assert!(RecordingStore::exists(dir.path()).await.unwrap());
let loaded_store = RecordingStore::load(dir.path()).await.unwrap();
assert_eq!(loaded_store.manifest.entries.len(), 0);
}
#[tokio::test]
async fn test_creating_updating_and_closing_entries() {
let dir = TempDir::new("qmdl_store_test").unwrap();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
let _ = store.new_entry().await.unwrap();
let entry_index = store.current_entry.unwrap();
assert_eq!(RecordingStore::read_manifest(dir.path()).await.unwrap(), store.manifest);
assert!(store.manifest.entries[entry_index].last_message_time.is_none());
store.update_entry_qmdl_size(entry_index, 1000).await.unwrap();
let entry = store.entry_for_name(&store.manifest.entries[entry_index].name).unwrap();
assert!(entry.last_message_time.is_some());
assert_eq!(store.manifest.entries[entry_index].qmdl_size_bytes, 1000);
assert_eq!(RecordingStore::read_manifest(dir.path()).await.unwrap(), store.manifest);
store.close_current_entry().await.unwrap();
assert!(matches!(store.close_current_entry().await, Err(RecordingStoreError::NoCurrentEntry)));
}
#[tokio::test]
async fn test_repeated_new_entries() {
let dir = TempDir::new("qmdl_store_test").unwrap();
let mut store = RecordingStore::create(dir.path()).await.unwrap();
let _ = store.new_entry().await.unwrap();
let entry_index = store.current_entry.unwrap();
let _ = store.new_entry().await.unwrap();
let new_entry_index = store.current_entry.unwrap();
assert_ne!(entry_index, new_entry_index);
assert_eq!(store.manifest.entries.len(), 2);
}
}

View File

@@ -1,58 +0,0 @@
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 tokio::io::AsyncReadExt;
use tokio::sync::mpsc::Sender;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio_util::io::ReaderStream;
use include_dir::{include_dir, Dir};
use crate::DiagDeviceCtrlMessage;
use crate::qmdl_store::RecordingStore;
pub struct ServerState {
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
pub readonly_mode: bool
}
pub async fn get_qmdl(State(state): State<Arc<ServerState>>, Path(qmdl_name): Path<String>) -> Result<Response, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await;
let entry = qmdl_store.entry_for_name(&qmdl_name)
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?;
let qmdl_file = qmdl_store.open_entry_qmdl(&entry).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 qmdl_stream = ReaderStream::new(limited_qmdl_file);
let headers = [(CONTENT_TYPE, "application/octet-stream")];
let body = Body::from_stream(qmdl_stream);
Ok((headers, body).into_response())
}
// 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");
pub async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
let path = path.trim_start_matches('/');
let mime_type = mime_guess::from_path(path).first_or_text_plain();
match STATIC_DIR.get_file(path) {
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap(),
Some(file) => Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_str(mime_type.as_ref()).unwrap(),
)
.body(Body::from(file.contents()))
.unwrap(),
}
}

View File

@@ -1,40 +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: #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,42 +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>
</tr>
</thead>
</table>
<div>
<h3>System stats</h3>
<pre id="system-stats">Loading...</pre>
</div>
<div>
<h3>Analysis Report</h3>
<pre id="analysis-report">Loading...</pre>
</div>
</body>
</html>

View File

@@ -1,106 +0,0 @@
async function populateDivs() {
const systemStats = await getSystemStats();
const systemStatsDiv = document.getElementById('system-stats');
systemStatsDiv.innerHTML = JSON.stringify(systemStats, null, 2);
const analysisReport = await getAnalysisReport();
const analysisReportDiv = document.getElementById('analysis-report');
analysisReportDiv.innerHTML = JSON.stringify(analysisReport, null, 2);
const qmdlManifest = await getQmdlManifest();
updateQmdlManifestTable(qmdlManifest);
}
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);
row.classList.add('current');
table.appendChild(row)
}
for (let entry of manifest.entries) {
table.appendChild(createEntryRow(entry));
}
}
function createEntryRow(entry) {
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 pcap_td = document.createElement('td');
const pcap_link = document.createElement('a');
pcap_link.href = `/api/pcap/${entry.name}`;
pcap_link.innerText = 'pcap';
pcap_td.appendChild(pcap_link);
row.appendChild(pcap_td);
const qmdl_td = document.createElement('td');
const qmdl_link = document.createElement('a');
qmdl_link.href = `/api/qmdl/${entry.name}`;
qmdl_link.innerText = 'qmdl';
qmdl_td.appendChild(qmdl_link);
row.appendChild(qmdl_td);
return row;
}
async function getAnalysisReport() {
const rows = await req('GET', '/api/analysis-report');
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.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.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);
}
}

8
book.toml Normal file
View File

@@ -0,0 +1,8 @@
[book]
authors = ["The Rayhunter Team"]
language = "en"
src = "doc"
title = "Rayhunter - An IMSI Catcher Catcher"
[output.html]
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"

14
check/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "rayhunter-check"
version = "0.5.1"
edition = "2024"
[dependencies]
rayhunter = { path = "../lib" }
futures = { version = "0.3.30", default-features = false }
log = "0.4.20"
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
pcap-file-tokio = "0.1.0"
clap = { version = "4.5.2", features = ["derive"] }
simple_logger = "5.0.0"
walkdir = "2.5.0"

221
check/src/main.rs Normal file
View File

@@ -0,0 +1,221 @@
use clap::Parser;
use futures::TryStreamExt;
use log::{debug, error, info, warn};
use pcap_file_tokio::pcapng::{Block, PcapNgReader};
use rayhunter::{
analysis::analyzer::{AnalysisRow, AnalyzerConfig, EventType, Harness},
diag::DataType,
gsmtap_parser,
pcap::GsmtapPcapWriter,
qmdl::QmdlReader,
};
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
use tokio::fs::File;
use walkdir::WalkDir;
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
#[arg(short = 'p', long)]
path: PathBuf,
#[arg(short = 'P', long)]
pcapify: bool,
#[arg(long)]
show_skipped: bool,
#[arg(short, long)]
quiet: bool,
#[arg(short, long)]
debug: bool,
}
#[derive(Default)]
struct Report {
skipped_reasons: HashMap<String, u32>,
total_messages: u32,
warnings: u32,
skipped: u32,
file_path: String,
}
impl Report {
fn new(file_path: &str) -> Self {
Report {
file_path: file_path.to_string(),
..Default::default()
}
}
fn process_row(&mut self, row: AnalysisRow) {
self.total_messages += 1;
if let Some(reason) = row.skipped_message_reason {
*self.skipped_reasons.entry(reason).or_insert(0) += 1;
self.skipped += 1;
return;
}
for maybe_event in row.events {
let Some(event) = maybe_event else { continue };
let Some(timestamp) = row.packet_timestamp else {
continue;
};
match event.event_type {
EventType::Informational => {
info!("{}: INFO - {} {}", self.file_path, timestamp, event.message,);
}
EventType::QualitativeWarning { severity } => {
warn!(
"{}: WARNING (Severity: {:?}) - {} {}",
self.file_path, severity, timestamp, event.message,
);
self.warnings += 1;
}
}
}
}
fn print_summary(&self, show_skipped: bool) {
if show_skipped && self.skipped > 0 {
info!("{}: messages skipped:", self.file_path);
for (reason, count) in self.skipped_reasons.iter() {
info!(" - {count}: \"{reason}\"");
}
}
info!(
"{}: {} messages analyzed, {} warnings, {} messages skipped",
self.file_path, self.total_messages, self.warnings, self.skipped
);
}
}
async fn analyze_pcap(pcap_path: &str, show_skipped: bool) {
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
let pcap_file = &mut File::open(&pcap_path).await.expect("failed to open file");
let mut pcap_reader = PcapNgReader::new(pcap_file)
.await
.expect("failed to read PCAP file");
let mut report = Report::new(pcap_path);
while let Some(Ok(block)) = pcap_reader.next_block().await {
let row = match block {
Block::EnhancedPacket(packet) => harness.analyze_pcap_packet(packet),
other => {
debug!("{pcap_path}: skipping pcap packet {other:?}");
continue;
}
};
report.process_row(row);
}
report.print_summary(show_skipped);
}
async fn analyze_qmdl(qmdl_path: &str, show_skipped: bool) {
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
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 mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
let mut qmdl_stream = pin!(
qmdl_reader
.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace))
);
let mut report = Report::new(qmdl_path);
while let Some(container) = qmdl_stream
.try_next()
.await
.expect("failed getting QMDL container")
{
for row in harness.analyze_qmdl_messages(container) {
report.process_row(row);
}
}
report.print_summary(show_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("pcapng");
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]
async fn main() {
let args = Args::parse();
let level = if args.debug {
log::LevelFilter::Debug
} else if args.quiet {
log::LevelFilter::Warn
} else {
log::LevelFilter::Info
};
simple_logger::SimpleLogger::new()
.with_colors(true)
.without_timestamps()
.with_level(level)
//Filter out a stupid massive amount of uneccesary warnings from hampi about undecoded extensions
.with_module_level("asn1_codecs", log::LevelFilter::Error)
.init()
.unwrap();
let harness = Harness::new_with_config(&AnalyzerConfig::default());
info!("Analyzers:");
for analyzer in harness.get_metadata().analyzers {
info!(
" - {} (v{}): {}",
analyzer.name, analyzer.version, analyzer.description
);
}
for maybe_entry in WalkDir::new(&args.path) {
let Ok(entry) = maybe_entry else {
error!("failed to open dir entry {maybe_entry:?}");
continue;
};
let name = entry.file_name();
let name_str = name.to_str().unwrap();
let path = entry.path();
let path_str = path.to_str().unwrap();
// instead of relying on the QMDL extension, can we check if a file is
// QMDL by inspecting the contents?
if name_str.ends_with(".qmdl") {
info!("**** Beginning analysis of {name_str}");
analyze_qmdl(path_str, args.show_skipped).await;
if args.pcapify {
pcapify(&path.to_path_buf()).await;
}
} else if name_str.ends_with(".pcap") || name_str.ends_with(".pcapng") {
// TODO: if we've already analyzed a QMDL, skip its corresponding pcap
info!("**** Beginning analysis of {name_str}");
analyze_pcap(path_str, args.show_skipped).await;
}
}
}

27
daemon/Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[package]
name = "rayhunter-daemon"
version = "0.5.1"
edition = "2024"
[dependencies]
rayhunter = { path = "../lib" }
toml = "0.8.8"
serde = { version = "1.0.193", features = ["derive"] }
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
thiserror = "1.0.52"
libc = "0.2.150"
log = "0.4.20"
env_logger = { version = "0.11", default-features = false }
tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] }
futures-macro = "0.3.30"
include_dir = "0.7.3"
chrono = { version = "0.4.31", features = ["serde"] }
tokio-stream = { version = "0.1.14", default-features = false }
futures = { version = "0.3.30", default-features = false }
serde_json = "1.0.114"
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
tempfile = "3.10.1"
async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98"
async-trait = "0.1.88"

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

265
daemon/src/analysis.rs Normal file
View File

@@ -0,0 +1,265 @@
use std::sync::Arc;
use std::{future, pin};
use axum::Json;
use axum::{
extract::{Path, State},
http::StatusCode,
};
use futures::TryStreamExt;
use log::{error, info};
use rayhunter::analysis::analyzer::{AnalyzerConfig, Harness};
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::qmdl::QmdlReader;
use serde::Serialize;
use tokio::fs::File;
use tokio::io::{AsyncWriteExt, BufWriter};
use tokio::sync::mpsc::Receiver;
use tokio::sync::{RwLock, RwLockWriteGuard};
use tokio_util::task::TaskTracker;
use crate::qmdl_store::RecordingStore;
use crate::server::ServerState;
pub struct AnalysisWriter {
writer: BufWriter<File>,
harness: Harness,
}
// We write our analysis results to a file immediately to minimize the amount of
// state Rayhunter has to keep track of in memory. The analysis file's format is
// Newline Delimited JSON
// (https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson), which
// lets us simply append new rows to the end without parsing the entire JSON
// object beforehand.
impl AnalysisWriter {
pub async fn new(file: File, analyzer_config: &AnalyzerConfig) -> Result<Self, std::io::Error> {
let harness = Harness::new_with_config(analyzer_config);
let mut result = Self {
writer: BufWriter::new(file),
harness,
};
let metadata = result.harness.get_metadata();
result.write(&metadata).await?;
Ok(result)
}
// Runs the analysis harness on the given container, serializing the results
// to the analysis file, returning the whether any warnings were detected
pub async fn analyze(&mut self, container: MessagesContainer) -> Result<bool, std::io::Error> {
let mut warning_detected = false;
for row in self.harness.analyze_qmdl_messages(container) {
if !row.is_empty() {
self.write(&row).await?;
}
warning_detected |= row.contains_warnings();
}
Ok(warning_detected)
}
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
let mut value_str = serde_json::to_string(value).unwrap();
value_str.push('\n');
self.writer.write_all(value_str.as_bytes()).await?;
self.writer.flush().await?;
Ok(())
}
// Flushes any pending I/O to disk before dropping the writer
pub async fn close(mut self) -> Result<(), std::io::Error> {
self.writer.flush().await?;
Ok(())
}
}
#[derive(Debug, Serialize, Clone)]
pub struct AnalysisStatus {
queued: Vec<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 {
NewFilesQueued,
RecordingFinished(String),
Exit,
}
async fn queued_len(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) -> usize {
analysis_status_lock.read().await.queued.len()
}
async fn dequeue_to_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) -> String {
let mut analysis_status = analysis_status_lock.write().await;
let name = analysis_status.queued.remove(0);
assert!(analysis_status.running.is_none());
analysis_status.running = Some(name.clone());
name
}
async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) {
let mut analysis_status = analysis_status_lock.write().await;
let finished = analysis_status.running.take().unwrap();
analysis_status.finished.push(finished);
}
async fn perform_analysis(
name: &str,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analyzer_config: &AnalyzerConfig,
) -> Result<(), String> {
info!("Opening QMDL and analysis file for {name}...");
let (analysis_file, qmdl_file) = {
let mut qmdl_store = qmdl_store_lock.write().await;
let (entry_index, _) = qmdl_store
.entry_for_name(name)
.ok_or(format!("failed to find QMDL store entry for {name}"))?;
let analysis_file = qmdl_store
.clear_and_open_entry_analysis(entry_index)
.await
.map_err(|e| format!("{e:?}"))?;
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|e| format!("{e:?}"))?;
(analysis_file, qmdl_file)
};
let mut analysis_writer = AnalysisWriter::new(analysis_file, analyzer_config)
.await
.map_err(|e| format!("{e:?}"))?;
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_stream = pin::pin!(
qmdl_reader
.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace))
);
info!("Starting analysis for {name}...");
while let Some(container) = qmdl_stream
.try_next()
.await
.expect("failed getting QMDL container")
{
let _ = analysis_writer
.analyze(container)
.await
.map_err(|e| format!("{e:?}"))?;
}
analysis_writer
.close()
.await
.map_err(|e| format!("{e:?}"))?;
info!("Analysis for {name} complete!");
Ok(())
}
pub fn run_analysis_thread(
task_tracker: &TaskTracker,
mut analysis_rx: Receiver<AnalysisCtrlMessage>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
analyzer_config: AnalyzerConfig,
) {
task_tracker.spawn(async move {
loop {
match analysis_rx.recv().await {
Some(AnalysisCtrlMessage::NewFilesQueued) => {
let count = queued_len(analysis_status_lock.clone()).await;
for _ in 0..count {
let name = dequeue_to_running(analysis_status_lock.clone()).await;
if let Err(err) =
perform_analysis(&name, qmdl_store_lock.clone(), &analyzer_config).await
{
error!("failed to analyze {name}: {err}");
}
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,
}
}
});
}
pub async fn get_analysis_status(
State(state): State<Arc<ServerState>>,
) -> Result<Json<AnalysisStatus>, (StatusCode, String)> {
Ok(Json(state.analysis_status_lock.read().await.clone()))
}
fn queue_qmdl(name: &str, analysis_status: &mut RwLockWriteGuard<AnalysisStatus>) -> bool {
if analysis_status.queued.iter().any(|n| n == name)
|| analysis_status.running.iter().any(|n| n == name)
{
return false;
}
analysis_status.queued.push(name.to_string());
true
}
pub async fn start_analysis(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<(StatusCode, Json<AnalysisStatus>), (StatusCode, String)> {
let mut analysis_status = state.analysis_status_lock.write().await;
let store = state.qmdl_store_lock.read().await;
let queued = if qmdl_name.is_empty() {
let mut entry_names: Vec<&str> = store
.manifest
.entries
.iter()
.map(|e| e.name.as_str())
.collect();
if let Some(current_entry) = store.current_entry {
entry_names.remove(current_entry);
}
entry_names
.iter()
.any(|name| queue_qmdl(name, &mut analysis_status))
} else {
queue_qmdl(&qmdl_name, &mut analysis_status)
};
if queued {
state
.analysis_sender
.send(AnalysisCtrlMessage::NewFilesQueued)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to queue new analysis files: {e:?}"),
)
})?;
}
Ok((StatusCode::ACCEPTED, Json(analysis_status.clone())))
}

62
daemon/src/config.rs Normal file
View File

@@ -0,0 +1,62 @@
use log::warn;
use serde::{Deserialize, Serialize};
use rayhunter::Device;
use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Config {
pub qmdl_store_path: String,
pub port: u16,
pub debug_mode: bool,
pub device: Device,
pub ui_level: u8,
pub colorblind_mode: bool,
pub key_input_mode: u8,
pub analyzers: AnalyzerConfig,
}
impl Default for Config {
fn default() -> Self {
Config {
qmdl_store_path: "/data/rayhunter/qmdl".to_string(),
port: 8080,
debug_mode: false,
device: Device::Orbic,
ui_level: 1,
colorblind_mode: false,
key_input_mode: 0,
analyzers: AnalyzerConfig::default(),
}
}
}
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
where
P: AsRef<std::path::Path>,
{
if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
} else {
warn!("unable to read config file, using default config");
Ok(Config::default())
}
}
pub struct Args {
pub config_path: String,
}
pub fn parse_args() -> Args {
let args: Vec<String> = std::env::args().collect();
if args.len() != 2 {
println!("Usage: {} /path/to/config/file", args[0]);
std::process::exit(1);
}
Args {
config_path: args[1].clone(),
}
}

417
daemon/src/diag.rs Normal file
View File

@@ -0,0 +1,417 @@
use std::ops::DerefMut;
use std::pin::pin;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt};
use log::{debug, error, info, warn};
use rayhunter::analysis::analyzer::AnalyzerConfig;
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter;
use tokio::fs::File;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::{RwLock, oneshot};
use tokio_util::io::ReaderStream;
use tokio_util::task::TaskTracker;
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
use crate::display;
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::server::ServerState;
pub enum DiagDeviceCtrlMessage {
StopRecording,
StartRecording,
DeleteEntry {
name: String,
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
},
DeleteAllEntries {
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
},
Exit,
}
pub struct DiagTask {
ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
state: DiagState,
}
enum DiagState {
Recording {
qmdl_writer: QmdlWriter<File>,
analysis_writer: Box<AnalysisWriter>,
},
Stopped,
}
impl DiagTask {
fn new(
ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
) -> Self {
Self {
ui_update_sender,
analysis_sender,
analyzer_config,
state: DiagState::Stopped,
}
}
/// Start recording
async fn start(&mut self, qmdl_store: &mut RecordingStore) {
let (qmdl_file, analysis_file) = qmdl_store
.new_entry()
.await
.expect("failed creating QMDL file entry");
self.stop_current_recording().await;
let qmdl_writer = QmdlWriter::new(qmdl_file);
let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config)
.await
.map(Box::new)
.expect("failed to write to analysis file");
self.state = DiagState::Recording {
qmdl_writer,
analysis_writer,
};
if let Err(e) = self
.ui_update_sender
.send(display::DisplayState::Recording)
.await
{
warn!("couldn't send ui update message: {e}");
}
}
/// Stop recording
async fn stop(&mut self, qmdl_store: &mut RecordingStore) {
self.stop_current_recording().await;
if let Some((_, entry)) = qmdl_store.get_current_entry() {
if let Err(e) = self
.analysis_sender
.send(AnalysisCtrlMessage::RecordingFinished(
entry.name.to_string(),
))
.await
{
warn!("couldn't send analysis message: {e}");
}
}
if let Err(e) = qmdl_store.close_current_entry().await {
error!("couldn't close current entry: {e}");
}
if let Err(e) = self
.ui_update_sender
.send(display::DisplayState::Paused)
.await
{
warn!("couldn't send ui update message: {e}");
}
}
async fn delete_entry(
&mut self,
qmdl_store: &mut RecordingStore,
name: &str,
) -> Result<(), RecordingStoreError> {
if qmdl_store.is_current_entry(name) {
self.stop(qmdl_store).await;
}
let res = qmdl_store.delete_entry(name).await;
if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entry {e}");
}
res
}
async fn delete_all_entries(
&mut self,
qmdl_store: &mut RecordingStore,
) -> Result<(), RecordingStoreError> {
self.stop(qmdl_store).await;
let res = qmdl_store.delete_all_entries().await;
if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entries {e}");
}
res
}
async fn stop_current_recording(&mut self) {
let mut state = DiagState::Stopped;
std::mem::swap(&mut self.state, &mut state);
if let DiagState::Recording {
analysis_writer, ..
} = state
{
analysis_writer
.close()
.await
.expect("failed to close analysis writer");
}
}
async fn process_container(
&mut self,
qmdl_store: &mut RecordingStore,
container: MessagesContainer,
) {
if container.data_type != DataType::UserSpace {
debug!("skipping non-userspace diag messages...");
return;
}
// keep track of how many bytes were written to the QMDL file so we can read
// a valid block of data from it in the HTTP server
if let DiagState::Recording {
qmdl_writer,
analysis_writer,
} = &mut self.state
{
qmdl_writer
.write_container(&container)
.await
.expect("failed to write to QMDL writer");
debug!(
"total QMDL bytes written: {}, updating manifest...",
qmdl_writer.total_written
);
let index = qmdl_store
.current_entry
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
qmdl_store
.update_entry_qmdl_size(index, qmdl_writer.total_written)
.await
.expect("failed to update qmdl file size");
debug!("done!");
let heuristic_warning = analysis_writer
.analyze(container)
.await
.expect("failed to analyze container");
if heuristic_warning {
info!("a heuristic triggered on this run!");
self.ui_update_sender
.send(display::DisplayState::WarningDetected)
.await
.expect("couldn't send ui update message: {}");
}
} else {
debug!("no qmdl_writer set, continuing...");
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn run_diag_read_thread(
task_tracker: &TaskTracker,
mut dev: DiagDevice,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
ui_update_sender: Sender<display::DisplayState>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
) {
task_tracker.spawn(async move {
let mut diag_stream = pin!(dev.as_stream().into_stream());
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config);
qmdl_file_tx
.send(DiagDeviceCtrlMessage::StartRecording)
.await
.unwrap();
loop {
tokio::select! {
msg = qmdl_file_rx.recv() => {
match msg {
Some(DiagDeviceCtrlMessage::StartRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.start(qmdl_store.deref_mut()).await;
},
Some(DiagDeviceCtrlMessage::StopRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.stop(qmdl_store.deref_mut()).await;
},
// None means all the Senders have been dropped, so it's
// time to go
Some(DiagDeviceCtrlMessage::Exit) | None => {
info!("Diag reader thread exiting...");
diag_task.stop_current_recording().await;
return Ok(())
},
Some(DiagDeviceCtrlMessage::DeleteEntry { name, response_tx }) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let resp = diag_task.delete_entry(qmdl_store.deref_mut(), name.as_str()).await;
if response_tx.send(resp).is_err() {
error!("Failed to send delete entry respons, receiver dropped");
}
},
Some(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx }) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let resp = diag_task.delete_all_entries(qmdl_store.deref_mut()).await;
if response_tx.send(resp).is_err() {
error!("Failed to send delete all entries respons, receiver dropped");
}
},
}
}
maybe_container = diag_stream.next() => {
match maybe_container.unwrap() {
Ok(container) => {
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.process_container(qmdl_store.deref_mut(), container).await
},
Err(err) => {
error!("error reading diag device: {err}");
return Err(err);
}
}
}
}
}
});
}
/// Start recording API for web thread
pub async fn start_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.config.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()))
}
/// Stop recording API for web thread
pub async fn stop_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.config.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.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let (response_tx, response_rx) = oneshot::channel();
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::DeleteEntry {
name: qmdl_name.clone(),
response_tx,
})
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send delete entry message: {e}"),
)
})?;
match response_rx.await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive delete response: {e}"),
)
})? {
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Err(RecordingStoreError::NoSuchEntryError) => Err((
StatusCode::BAD_REQUEST,
format!("no recording with name {qmdl_name}"),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recording: {e}"),
)),
}
}
pub async fn delete_all_recordings(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let (response_tx, response_rx) = oneshot::channel();
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx })
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send delete all entries message: {e}"),
)
})?;
match response_rx.await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive delete all response: {e}"),
)
})? {
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recordings: {e}"),
)),
}
}
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 (entry_index, _) = if qmdl_name == "live" {
qmdl_store.get_current_entry().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string(),
))?
} else {
qmdl_store.entry_for_name(&qmdl_name).ok_or((
StatusCode::NOT_FOUND,
format!("Couldn't find QMDL entry with name \"{qmdl_name}\""),
))?
};
let analysis_file = qmdl_store
.open_entry_analysis(entry_index)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
let analysis_stream = ReaderStream::new(analysis_file);
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
let body = Body::from_stream(analysis_stream);
Ok((headers, body).into_response())
}

View File

@@ -0,0 +1,203 @@
use async_trait::async_trait;
use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::FilterType};
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 include_dir::{Dir, include_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,
}
}
}
#[async_trait]
pub trait GenericFramebuffer: Send + 'static {
fn dimensions(&self) -> Dimensions;
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>); // rgb, row-wise, left-to-right, top-to-bottom
async 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).await
}
async fn draw_gif(&mut self, img_buffer: &[u8]) {
let cursor = Cursor::new(img_buffer);
if let Ok(decoder) = GifDecoder::new(cursor) {
let frames: Vec<_> = decoder
.into_frames()
.filter_map(|f| f.ok())
.map(|frame| {
let (numerator, _) = frame.delay().numer_denom_ms();
let img = DynamicImage::from(frame.into_buffer());
(img, numerator as u64)
})
.collect();
for (img, delay_ms) in frames {
self.write_dynamic_image(img).await;
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
}
}
}
async fn draw_img(&mut self, img_buffer: &[u8]) {
let img = image::load_from_memory(img_buffer).unwrap();
self.write_dynamic_image(img).await
}
async 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).await
}
}
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(async 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()).await,
3 => fb.draw_img(img.unwrap()).await,
128 => {
fb.draw_line(Color::Cyan, 128).await;
fb.draw_line(Color::Pink, 102).await;
fb.draw_line(Color::White, 76).await;
fb.draw_line(Color::Pink, 50).await;
fb.draw_line(Color::Cyan, 25).await;
}
// this branch id for ui_level 1, which is also the default if an
// unknown value is used
_ => {}
};
fb.draw_line(display_color, 2).await;
tokio::time::sleep(Duration::from_millis(1000)).await;
}
});
}

View File

@@ -0,0 +1,16 @@
use log::info;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::display::DisplayState;
pub fn update_ui(
_task_tracker: &TaskTracker,
_config: &config::Config,
_ui_shutdown_rx: oneshot::Receiver<()>,
_ui_update_rx: Receiver<DisplayState>,
) {
info!("Headless mode, not spawning UI.");
}

16
daemon/src/display/mod.rs Normal file
View File

@@ -0,0 +1,16 @@
mod generic_framebuffer;
pub mod headless;
pub mod orbic;
pub mod tmobile;
pub mod tplink;
pub mod tplink_framebuffer;
pub mod tplink_onebit;
pub mod wingtech;
#[derive(Clone, Copy, PartialEq)]
pub enum DisplayState {
Recording,
Paused,
WarningDetected,
}

View File

@@ -0,0 +1,51 @@
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use async_trait::async_trait;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)]
struct Framebuffer;
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
Dimensions {
height: 128,
width: 128,
}
}
async fn write_buffer(&mut self, buffer: Vec<(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());
}
tokio::fs::write(FB_PATH, &raw_buffer).await.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,
)
}

View File

@@ -0,0 +1,81 @@
/// Display module for Tmobile TMOHS1, blink LEDs on the front of the device.
/// DisplayState::Recording => Signal LED slowly blinks blue.
/// DisplayState::Paused => WiFi LED blinks white.
/// DisplayState::WarningDetected => Signal LED slowly blinks red.
use log::{error, info};
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use std::time::Duration;
use crate::config;
use crate::display::DisplayState;
macro_rules! led {
($l:expr) => {{ format!("/sys/class/leds/led:{}/blink", $l) }};
}
async fn start_blinking(path: String) {
tokio::fs::write(&path, "1").await.ok();
}
async fn stop_blinking(path: String) {
tokio::fs::write(&path, "0").await.ok();
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: mpsc::Receiver<DisplayState>,
) {
let mut invisible: bool = false;
if config.ui_level == 0 {
info!("Invisible mode, not spawning UI.");
invisible = true;
}
task_tracker.spawn(async move {
let mut state = DisplayState::Recording;
let mut last_state = DisplayState::Paused;
loop {
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
}
Err(oneshot::error::TryRecvError::Empty) => {}
Err(e) => panic!("error receiving shutdown message: {e}"),
}
match ui_update_rx.try_recv() {
Ok(new_state) => state = new_state,
Err(mpsc::error::TryRecvError::Empty) => {}
Err(e) => error!("error receiving ui update message: {e}"),
};
if invisible || state == last_state {
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
match state {
DisplayState::Paused => {
stop_blinking(led!("signal_blue")).await;
stop_blinking(led!("signal_red")).await;
start_blinking(led!("wlan_white")).await;
}
DisplayState::Recording => {
stop_blinking(led!("wlan_white")).await;
stop_blinking(led!("signal_red")).await;
start_blinking(led!("signal_blue")).await;
}
DisplayState::WarningDetected => {
stop_blinking(led!("wlan_white")).await;
stop_blinking(led!("signal_blue")).await;
start_blinking(led!("signal_red")).await;
}
}
last_state = state;
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
}

View File

@@ -0,0 +1,31 @@
use log::info;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
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.");
}
// Since this is a one-time check at startup, using sync is acceptable
// The alternative would be to make the entire initialization async
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
info!("detected one-bit display");
tplink_onebit::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
} else {
info!("fallback to framebuffer");
tplink_framebuffer::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
}
}

View File

@@ -0,0 +1,93 @@
use async_trait::async_trait;
use std::os::fd::AsRawFd;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0";
struct Framebuffer;
#[repr(C)]
struct fb_fillrect {
dx: u32,
dy: u32,
width: u32,
height: u32,
color: u32,
rop: u32,
}
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
Dimensions {
height: 128,
width: 128,
}
}
async fn write_buffer(&mut self, buffer: Vec<(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 = OpenOptions::new().write(true).open(FB_PATH).await.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).await.unwrap();
// ioctl is a synchronous operation, but it's fast enough that it shouldn't block
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,168 @@
/// 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::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(async 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) = tokio::fs::write(OLED_PATH, pixels).await {
error!("failed to write to display: {e}");
}
}
tokio::time::sleep(Duration::from_millis(1000)).await;
}
});
}
#[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

@@ -0,0 +1,56 @@
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
/// Display support for the Wingtech CT2MHS01 hotspot.
///
/// Tested on (from `/etc/wt_version`):
/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP
/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55
/// WT_HARDWARE_VERSION=89323_1_20
use async_trait::async_trait;
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;
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
Dimensions {
height: 128,
width: 160,
}
}
async fn write_buffer(&mut self, buffer: Vec<(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());
}
tokio::fs::write(FB_PATH, &raw_buffer).await.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,
)
}

View File

@@ -1,10 +1,10 @@
use thiserror::Error;
use rayhunter::diag_device::DiagDeviceError;
use thiserror::Error;
use crate::qmdl_store::RecordingStoreError;
#[derive(Error, Debug)]
pub enum RayhunterError{
pub enum RayhunterError {
#[error("Config file parsing error: {0}")]
ConfigFileParsingError(#[from] toml::de::Error),
#[error("Diag intialization error: {0}")]
@@ -13,6 +13,6 @@ pub enum RayhunterError{
TokioError(#[from] tokio::io::Error),
#[error("QmdlStore error: {0}")]
QmdlStoreError(#[from] RecordingStoreError),
#[error("No QMDL store found at path {0}, but can't create a new one due to readonly mode")]
NoStoreReadonlyMode(String),
#[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")]
NoStoreDebugMode(String),
}

131
daemon/src/key_input.rs Normal file
View File

@@ -0,0 +1,131 @@
use log::{error, info};
use std::time::{Duration, Instant};
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use tokio::sync::mpsc::Sender;
use tokio::sync::oneshot;
use tokio_util::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>,
mut ui_shutdown_rx: oneshot::Receiver<()>,
) {
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;
let mut last_event_time: Option<Instant> = None;
loop {
tokio::select! {
_ = &mut ui_shutdown_rx => {
info!("received key input shutdown");
return;
}
result = file.read_exact(&mut buffer) => {
if let Err(e) = result {
error!("failed to read key input: {e}");
return;
}
}
}
let event = parse_event(buffer);
let now = Instant::now();
// On orbic it was observed that pressing the power button can trigger many successive
// events. Drop events that are too close together.
if let Some(last_time) = last_event_time {
if now.duration_since(last_time) < Duration::from_millis(50) {
last_event_time = Some(now);
continue;
}
}
last_event_time = Some(now);
match event {
Event::KeyUp => {
if let Some(last_keyup_instant) = last_keyup {
let elapsed = now.duration_since(last_keyup_instant);
if elapsed >= Duration::from_millis(100)
&& elapsed <= Duration::from_millis(800)
{
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;
continue;
}
}
last_keyup = Some(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));
}
}

310
daemon/src/main.rs Normal file
View File

@@ -0,0 +1,310 @@
mod analysis;
mod config;
mod diag;
mod display;
mod error;
mod key_input;
mod pcap;
mod qmdl_store;
mod server;
mod stats;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::config::{parse_args, parse_config};
use crate::diag::run_diag_read_thread;
use crate::error::RayhunterError;
use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore;
use crate::server::{ServerState, get_config, get_qmdl, get_zip, serve_static, set_config};
use crate::stats::{get_qmdl_manifest, get_system_stats};
use analysis::{
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
};
use axum::Router;
use axum::response::Redirect;
use axum::routing::{get, post};
use diag::{
DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
start_recording, stop_recording,
};
use log::{error, info};
use qmdl_store::RecordingStoreError;
use rayhunter::Device;
use rayhunter::diag_device::DiagDevice;
use tokio::net::TcpListener;
use tokio::select;
use tokio::sync::mpsc::{self, Sender};
use tokio::sync::{RwLock, oneshot};
use tokio::task::JoinHandle;
use tokio_util::task::TaskTracker;
type AppRouter = Router<Arc<ServerState>>;
fn get_router() -> AppRouter {
Router::new()
.route("/api/pcap/{name}", get(get_pcap))
.route("/api/qmdl/{name}", get(get_qmdl))
.route("/api/zip/{name}", get(get_zip))
.route("/api/system-stats", get(get_system_stats))
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
.route("/api/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("/api/config", get(get_config))
.route("/api/config", post(set_config))
.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
// ServerState and a oneshot Receiver that'll fire when it's time to shutdown
// (i.e. user hit ctrl+c)
async fn run_server(
task_tracker: &TaskTracker,
state: Arc<ServerState>,
server_shutdown_rx: oneshot::Receiver<()>,
) -> JoinHandle<()> {
info!("spinning up server");
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
let listener = TcpListener::bind(&addr).await.unwrap();
let app = get_router().with_state(state);
task_tracker.spawn(async move {
info!("The orca is hunting for stingrays...");
axum::serve(listener, app)
.with_graceful_shutdown(server_shutdown_signal(server_shutdown_rx))
.await
.unwrap();
})
}
async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
server_shutdown_rx.await.unwrap();
info!("Server received shutdown signal, exiting...");
}
// Loads a RecordingStore if one exists, and if not, only create one if we're
// not in debug mode. If we fail to parse the manifest AND we're not in debug
// mode, try to recover the manifest from the existing QMDL files
async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
let store_exists = RecordingStore::exists(&config.qmdl_store_path).await?;
if config.debug_mode {
if store_exists {
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!("recovering manifest from existing QMDL files...");
Ok(RecordingStore::recover(&config.qmdl_store_path).await?)
}
Err(err) => Err(err.into()),
}
} else {
Ok(RecordingStore::create(&config.qmdl_store_path).await?)
}
}
// Start a thread that'll track when user hits ctrl+c. When that happens,
// trigger various cleanup tasks, including sending signals to other threads to
// shutdown
#[allow(clippy::too_many_arguments)]
fn run_shutdown_thread(
task_tracker: &TaskTracker,
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
daemon_restart_rx: oneshot::Receiver<()>,
should_restart_flag: Arc<AtomicBool>,
server_shutdown_tx: oneshot::Sender<()>,
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
maybe_key_input_shutdown_tx: Option<oneshot::Sender<()>>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_tx: Sender<AnalysisCtrlMessage>,
) -> JoinHandle<Result<(), RayhunterError>> {
info!("create shutdown thread");
task_tracker.spawn(async move {
select! {
res = tokio::signal::ctrl_c() => {
if let Err(err) = res {
error!("Unable to listen for shutdown signal: {err}");
}
should_restart_flag.store(false, Ordering::Relaxed);
}
res = daemon_restart_rx => {
if let Err(err) = res {
error!("Unable to listen for shutdown signal: {err}");
}
should_restart_flag.store(true, Ordering::Relaxed);
}
};
let mut qmdl_store = qmdl_store_lock.write().await;
if qmdl_store.current_entry.is_some() {
info!("Closing current QMDL entry...");
qmdl_store.close_current_entry().await?;
info!("Done!");
}
server_shutdown_tx
.send(())
.expect("couldn't send server shutdown signal");
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
let _ = ui_shutdown_tx.send(());
}
if let Some(key_input_shutdown_tx) = maybe_key_input_shutdown_tx {
let _ = key_input_shutdown_tx.send(());
}
diag_device_sender
.send(DiagDeviceCtrlMessage::Exit)
.await
.expect("couldn't send Exit message to diag thread");
analysis_tx
.send(AnalysisCtrlMessage::Exit)
.await
.expect("couldn't send Exit message to analysis thread");
Ok(())
})
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), RayhunterError> {
env_logger::init();
let args = parse_args();
loop {
let config = parse_config(&args.config_path).await?;
if !run_with_config(&args, config).await? {
return Ok(());
}
}
}
async fn run_with_config(
args: &config::Args,
config: config::Config,
) -> Result<bool, RayhunterError> {
// TaskTrackers give us an interface to spawn tokio threads, and then
// eventually await all of them ending
let task_tracker = TaskTracker::new();
println!("R A Y H U N T E R 🐳");
let store = init_qmdl_store(&config).await?;
let analysis_status = AnalysisStatus::new(&store);
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 mut maybe_ui_shutdown_tx = None;
let mut maybe_key_input_shutdown_tx = None;
if !config.debug_mode {
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
info!("Using configuration for device: {0:?}", config.device);
let mut dev = DiagDevice::new(&config.device)
.await
.map_err(RayhunterError::DiagInitError)?;
dev.config_logs()
.await
.map_err(RayhunterError::DiagInitError)?;
info!("Starting Diag Thread");
run_diag_read_thread(
&task_tracker,
dev,
diag_rx,
diag_tx.clone(),
ui_update_tx.clone(),
qmdl_store_lock.clone(),
analysis_tx.clone(),
config.analyzers.clone(),
);
info!("Starting UI");
let update_ui = match &config.device {
Device::Orbic => display::orbic::update_ui,
Device::Tplink => display::tplink::update_ui,
Device::Tmobile => display::tmobile::update_ui,
Device::Wingtech => display::wingtech::update_ui,
Device::Pinephone => display::headless::update_ui,
};
update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
info!("Starting Key Input service");
let (key_input_shutdown_tx, key_input_shutdown_rx) = oneshot::channel();
maybe_key_input_shutdown_tx = Some(key_input_shutdown_tx);
key_input::run_key_input_thread(
&task_tracker,
&config,
diag_tx.clone(),
key_input_shutdown_rx,
);
}
let (daemon_restart_tx, daemon_restart_rx) = oneshot::channel::<()>();
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
run_analysis_thread(
&task_tracker,
analysis_rx,
qmdl_store_lock.clone(),
analysis_status_lock.clone(),
config.analyzers.clone(),
);
let should_restart_flag = Arc::new(AtomicBool::new(false));
run_shutdown_thread(
&task_tracker,
diag_tx.clone(),
daemon_restart_rx,
should_restart_flag.clone(),
server_shutdown_tx,
maybe_ui_shutdown_tx,
maybe_key_input_shutdown_tx,
qmdl_store_lock.clone(),
analysis_tx.clone(),
);
let state = Arc::new(ServerState {
config_path: args.config_path.clone(),
config,
qmdl_store_lock: qmdl_store_lock.clone(),
diag_device_ctrl_sender: diag_tx,
analysis_status_lock,
analysis_sender: analysis_tx,
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
});
run_server(&task_tracker, state, server_shutdown_rx).await;
task_tracker.close();
task_tracker.wait().await;
info!("see you space cowboy...");
Ok(should_restart_flag.load(Ordering::Relaxed))
}
#[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();
}
}

93
daemon/src/pcap.rs Normal file
View File

@@ -0,0 +1,93 @@
use crate::ServerState;
use anyhow::Error;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use log::error;
use rayhunter::diag::DataType;
use rayhunter::gsmtap_parser;
use rayhunter::pcap::GsmtapPcapWriter;
use rayhunter::qmdl::QmdlReader;
use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncWrite, duplex};
use tokio_util::io::ReaderStream;
// 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
// pcap data to a channel that's piped to the client.
pub async fn get_pcap(
State(state): State<Arc<ServerState>>,
Path(mut qmdl_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await;
if qmdl_name.ends_with("pcapng") {
qmdl_name = qmdl_name.trim_end_matches(".pcapng").to_string();
}
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or((
StatusCode::NOT_FOUND,
format!("couldn't find manifest entry with name {qmdl_name}"),
))?;
if entry.qmdl_size_bytes == 0 {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"QMDL file is empty, try again in a bit!".to_string(),
));
}
let qmdl_size_bytes = entry.qmdl_size_bytes;
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
// the QMDL reader should stop at the last successfully written data chunk
// (entry.size_bytes)
let (reader, writer) = duplex(1024);
tokio::spawn(async move {
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
error!("failed to generate PCAP: {e:?}");
}
});
let headers = [(CONTENT_TYPE, "application/vnd.tcpdump.pcap")];
let body = Body::from_stream(ReaderStream::new(reader));
Ok((headers, body).into_response())
}
pub async fn generate_pcap_data<R, W>(
writer: W,
qmdl_file: R,
qmdl_size_bytes: usize,
) -> Result<(), Error>
where
W: AsyncWrite + Unpin + Send,
R: AsyncRead + Unpin,
{
let mut pcap_writer = GsmtapPcapWriter::new(writer).await?;
pcap_writer.write_iface_header().await?;
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
while let Some(container) = reader.get_next_messages_container().await? {
if container.data_type != DataType::UserSpace {
continue;
}
for maybe_msg in container.into_messages() {
match maybe_msg {
Ok(msg) => {
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
pcap_writer
.write_gsmtap_message(gsmtap_msg, timestamp)
.await?;
}
}
Err(e) => error!("error parsing message: {e:?}"),
}
}
}
Ok(())
}

520
daemon/src/qmdl_store.rs Normal file
View File

@@ -0,0 +1,520 @@
use std::io::{self, ErrorKind};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Local};
use log::{info, warn};
use rayhunter::util::RuntimeMetadata;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::{
fs::{self, File, OpenOptions, try_exists},
io::AsyncWriteExt,
};
#[derive(Debug, Error)]
pub enum RecordingStoreError {
#[error("Can't close an entry when there's no current entry")]
NoCurrentEntry,
#[error("An entry with that name doesn't exist")]
NoSuchEntryError,
#[error("Couldn't create file: {0}")]
CreateFileError(tokio::io::Error),
#[error("Couldn't read file: {0}")]
ReadFileError(tokio::io::Error),
#[error("Couldn't delete file: {0}")]
DeleteFileError(tokio::io::Error),
#[error("Couldn't open directory at path: {0}")]
OpenDirError(tokio::io::Error),
#[error("Couldn't read manifest file: {0}")]
ReadManifestError(tokio::io::Error),
#[error("Couldn't write manifest file: {0}")]
WriteManifestError(tokio::io::Error),
#[error("Couldn't parse QMDL store manifest file: {0}")]
ParseManifestError(toml::de::Error),
}
pub struct RecordingStore {
pub path: PathBuf,
pub manifest: Manifest,
pub current_entry: Option<usize>, // index into manifest
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
pub struct Manifest {
pub entries: Vec<ManifestEntry>,
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
pub struct ManifestEntry {
pub name: String,
pub start_time: DateTime<Local>,
pub last_message_time: Option<DateTime<Local>>,
pub qmdl_size_bytes: usize,
pub rayhunter_version: Option<String>,
pub system_os: Option<String>,
pub arch: Option<String>,
}
impl ManifestEntry {
fn new() -> Self {
let now = Local::now();
let metadata = RuntimeMetadata::new();
ManifestEntry {
name: format!("{}", now.timestamp()),
start_time: now,
last_message_time: None,
qmdl_size_bytes: 0,
rayhunter_version: Some(metadata.rayhunter_version),
system_os: Some(metadata.system_os),
arch: Some(metadata.arch),
}
}
pub fn get_qmdl_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
let mut filepath = path.as_ref().join(&self.name);
filepath.set_extension("qmdl");
filepath
}
pub fn get_analysis_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
let mut filepath = path.as_ref().join(&self.name);
filepath.set_extension("ndjson");
filepath
}
}
impl RecordingStore {
// Returns whether a directory with a "manifest.toml" exists at the given
// path (though doesn't check if that manifest is valid)
pub async fn exists<P>(path: P) -> Result<bool, RecordingStoreError>
where
P: AsRef<Path>,
{
let manifest_path = path.as_ref().join("manifest.toml");
let dir_exists = try_exists(path)
.await
.map_err(RecordingStoreError::OpenDirError)?;
let manifest_exists = try_exists(manifest_path)
.await
.map_err(RecordingStoreError::ReadManifestError)?;
Ok(dir_exists && manifest_exists)
}
// Loads an existing RecordingStore at the given path. Errors if no store exists,
// or if it's malformed.
pub async fn load<P>(path: P) -> Result<Self, RecordingStoreError>
where
P: AsRef<Path>,
{
let path: PathBuf = path.as_ref().to_path_buf();
let manifest = RecordingStore::read_manifest(&path).await?;
Ok(RecordingStore {
path,
manifest,
current_entry: None,
})
}
// Creates a new RecordingStore at the given path. This involves creating a dir
// and writing an empty manifest.
pub async fn create<P>(path: P) -> Result<Self, RecordingStoreError>
where
P: AsRef<Path>,
{
fs::create_dir_all(&path)
.await
.map_err(RecordingStoreError::OpenDirError)?;
let mut store = RecordingStore {
path: path.as_ref().to_owned(),
manifest: Manifest {
entries: Vec::new(),
},
current_entry: None,
};
store.write_manifest().await?;
Ok(store)
}
// Does a best-effort attempt to recover the manifest from a directory of
// QMDL files. We expect these files to be named like "<timestamp>.qmdl",
// and skip any files which don't match that pattern.
pub async fn recover<P>(path: P) -> Result<Self, RecordingStoreError>
where
P: AsRef<Path>,
{
let mut dir_entries = fs::read_dir(path.as_ref())
.await
.map_err(RecordingStoreError::OpenDirError)?;
let mut manifest_entries = Vec::new();
while let Some(entry) = dir_entries
.next_entry()
.await
.map_err(RecordingStoreError::OpenDirError)?
{
let os_filename = entry.file_name();
let Some(filename) = os_filename.to_str() else {
continue;
};
if !filename.ends_with(".qmdl") {
continue;
}
let stem = filename.trim_end_matches(".qmdl");
let Ok(start_timestamp) = stem.parse::<i64>() else {
warn!("QMDL file has invalid name {os_filename:?}, skipping");
continue;
};
let metadata = match entry.metadata().await {
Ok(metadata) => metadata,
Err(err) => {
warn!("failed to read QMDL file metadata: {err:?}, skipping");
continue;
}
};
let Some(start_time) = DateTime::from_timestamp(start_timestamp, 0) else {
warn!("QMDL filename {os_filename:?} gave an invalid timestamp, skipping");
continue;
};
let Ok(last_message_time) = metadata.modified() else {
warn!("failed to get modified time for QMDL file {os_filename:?}, skipping");
continue;
};
info!("successfully recovered QMDL entry {os_filename:?}!");
manifest_entries.push(ManifestEntry {
name: stem.to_string(),
start_time: start_time.into(),
last_message_time: Some(last_message_time.into()),
qmdl_size_bytes: metadata.size() as usize,
rayhunter_version: None,
system_os: None,
arch: None,
});
}
// sort chronologically
manifest_entries.sort_by(|a, b| a.start_time.cmp(&b.start_time));
let mut store = RecordingStore {
path: path.as_ref().to_path_buf(),
manifest: Manifest {
entries: manifest_entries,
},
current_entry: None,
};
store.write_manifest().await?;
Ok(store)
}
async fn read_manifest<P>(path: P) -> Result<Manifest, RecordingStoreError>
where
P: AsRef<Path>,
{
let manifest_path = path.as_ref().join("manifest.toml");
let file_contents = fs::read_to_string(&manifest_path)
.await
.map_err(RecordingStoreError::ReadManifestError)?;
toml::from_str(&file_contents).map_err(RecordingStoreError::ParseManifestError)
}
// Closes the current entry (if needed), creates a new entry based on the
// current time, and updates the manifest. Returns a tuple of the entry's
// newly created QMDL file and analysis file.
pub async fn new_entry(&mut self) -> Result<(File, File), RecordingStoreError> {
// if we've already got an entry open, close it
if self.current_entry.is_some() {
self.close_current_entry().await?;
}
let new_entry = ManifestEntry::new();
let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path);
let qmdl_file = File::create(&qmdl_filepath)
.await
.map_err(RecordingStoreError::CreateFileError)?;
let analysis_filepath = new_entry.get_analysis_filepath(&self.path);
let analysis_file = File::create(&analysis_filepath)
.await
.map_err(RecordingStoreError::CreateFileError)?;
self.manifest.entries.push(new_entry);
self.current_entry = Some(self.manifest.entries.len() - 1);
self.write_manifest().await?;
Ok((qmdl_file, analysis_file))
}
// Returns the corresponding QMDL file for a given entry
pub async fn open_entry_qmdl(&self, entry_index: usize) -> Result<File, RecordingStoreError> {
let entry = &self.manifest.entries[entry_index];
File::open(entry.get_qmdl_filepath(&self.path))
.await
.map_err(RecordingStoreError::ReadFileError)
}
// Returns the corresponding QMDL file for a given entry
pub async fn open_entry_analysis(
&self,
entry_index: usize,
) -> Result<File, RecordingStoreError> {
let entry = &self.manifest.entries[entry_index];
File::open(entry.get_analysis_filepath(&self.path))
.await
.map_err(RecordingStoreError::ReadFileError)
}
pub async fn clear_and_open_entry_analysis(
&mut self,
entry_index: usize,
) -> Result<File, RecordingStoreError> {
let entry = &self.manifest.entries[entry_index];
let file = OpenOptions::new()
.write(true)
.truncate(true)
.open(entry.get_analysis_filepath(&self.path))
.await
.map_err(RecordingStoreError::ReadFileError)?;
Ok(file)
}
// Unsets the current entry
pub async fn close_current_entry(&mut self) -> Result<(), RecordingStoreError> {
match self.current_entry {
Some(_) => {
self.current_entry = None;
Ok(())
}
None => Err(RecordingStoreError::NoCurrentEntry),
}
}
// Sets the given entry's size and updates the last_message_time to now, updating the manifest
pub async fn update_entry_qmdl_size(
&mut self,
entry_index: usize,
size_bytes: usize,
) -> Result<(), RecordingStoreError> {
self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes;
self.manifest.entries[entry_index].last_message_time = Some(Local::now());
self.write_manifest().await
}
async fn write_manifest(&mut self) -> Result<(), RecordingStoreError> {
// we don't technically need a mutable reference to `self` here, but it
// does prevent multiple concurrent writes across different threads
let tmp_path = self.path.join("manifest.toml.new");
let mut manifest_tmp_file = File::create(&tmp_path)
.await
.map_err(RecordingStoreError::WriteManifestError)?;
let manifest_contents =
toml::to_string_pretty(&self.manifest).expect("failed to serialize manifest");
manifest_tmp_file
.write_all(manifest_contents.as_bytes())
.await
.map_err(RecordingStoreError::WriteManifestError)?;
fs::rename(tmp_path, self.path.join("manifest.toml"))
.await
.map_err(RecordingStoreError::WriteManifestError)?;
Ok(())
}
// Finds an entry by filename
pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> {
let entry_index = self
.manifest
.entries
.iter()
.position(|entry| entry.name == name)?;
Some((entry_index, &self.manifest.entries[entry_index]))
}
pub fn get_current_entry(&self) -> Option<(usize, &ManifestEntry)> {
let entry_index = self.current_entry?;
Some((entry_index, &self.manifest.entries[entry_index]))
}
pub fn is_current_entry(&self, name: &str) -> bool {
match self.current_entry {
Some(idx) => match self.manifest.entries.get(idx) {
Some(entry) => entry.name == name,
None => false,
},
None => false,
}
}
pub async fn delete_entry(&mut self, name: &str) -> Result<(), RecordingStoreError> {
let entry_to_delete_idx = self
.manifest
.entries
.iter()
.position(|entry| entry.name == name)
.ok_or(RecordingStoreError::NoSuchEntryError)?;
match self.current_entry {
Some(current_entry) if current_entry == entry_to_delete_idx => {
self.close_current_entry().await?;
}
Some(current_entry) => {
self.current_entry = Some(current_entry - 1);
}
None => {}
};
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(())
}
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)]
mod tests {
use super::*;
use tempfile::{Builder, TempDir};
fn make_temp_dir() -> TempDir {
Builder::new().prefix("qmdl_store_test").tempdir().unwrap()
}
#[tokio::test]
async fn test_load_from_empty_dir() {
let dir = make_temp_dir();
assert!(!RecordingStore::exists(dir.path()).await.unwrap());
let _created_store = RecordingStore::create(dir.path()).await.unwrap();
assert!(RecordingStore::exists(dir.path()).await.unwrap());
let loaded_store = RecordingStore::load(dir.path()).await.unwrap();
assert_eq!(loaded_store.manifest.entries.len(), 0);
}
#[tokio::test]
async fn test_creating_updating_and_closing_entries() {
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();
assert_eq!(
RecordingStore::read_manifest(dir.path()).await.unwrap(),
store.manifest
);
assert!(
store.manifest.entries[entry_index]
.last_message_time
.is_none()
);
store
.update_entry_qmdl_size(entry_index, 1000)
.await
.unwrap();
let (entry_index, entry) = store
.entry_for_name(&store.manifest.entries[entry_index].name)
.unwrap();
assert!(entry.last_message_time.is_some());
assert_eq!(store.manifest.entries[entry_index].qmdl_size_bytes, 1000);
assert_eq!(
RecordingStore::read_manifest(dir.path()).await.unwrap(),
store.manifest
);
store.close_current_entry().await.unwrap();
assert!(matches!(
store.close_current_entry().await,
Err(RecordingStoreError::NoCurrentEntry)
));
}
#[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]
async fn test_repeated_new_entries() {
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();
let _ = store.new_entry().await.unwrap();
let new_entry_index = store.current_entry.unwrap();
assert_ne!(entry_index, new_entry_index);
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());
}
}

345
daemon/src/server.rs Normal file
View File

@@ -0,0 +1,345 @@
use anyhow::Error;
use async_zip::Compression;
use async_zip::ZipEntryBuilder;
use async_zip::tokio::write::ZipFileWriter;
use axum::Json;
use axum::body::Body;
use axum::extract::Path;
use axum::extract::State;
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use log::{error, warn};
use std::sync::Arc;
use tokio::fs::write;
use tokio::io::{AsyncReadExt, copy, duplex};
use tokio::sync::mpsc::Sender;
use tokio::sync::{RwLock, oneshot};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::ReaderStream;
use crate::DiagDeviceCtrlMessage;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::config::Config;
use crate::pcap::generate_pcap_data;
use crate::qmdl_store::RecordingStore;
pub struct ServerState {
pub config_path: String,
pub config: Config,
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
}
pub 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 (entry_index, entry) = qmdl_store.entry_for_name(qmdl_idx).ok_or((
StatusCode::NOT_FOUND,
format!("couldn't find qmdl file with name {qmdl_idx}"),
))?;
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("error opening QMDL file: {err}"),
)
})?;
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
let headers = [
(CONTENT_TYPE, "application/octet-stream"),
(CONTENT_LENGTH, &entry.qmdl_size_bytes.to_string()),
];
let body = Body::from_stream(qmdl_stream);
Ok((headers, body).into_response())
}
pub async fn serve_static(
State(_): State<Arc<ServerState>>,
Path(path): Path<String>,
) -> impl IntoResponse {
let path = path.trim_start_matches('/');
match path {
"rayhunter_icon.png" => (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
include_bytes!("../web/build/rayhunter_icon.png"),
)
.into_response(),
"rayhunter_orca_only.png" => (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
include_bytes!("../web/build/rayhunter_orca_only.png"),
)
.into_response(),
"rayhunter_text.png" => (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
include_bytes!("../web/build/rayhunter_text.png"),
)
.into_response(),
"favicon.png" => (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
include_bytes!("../web/build/favicon.png"),
)
.into_response(),
"index.html" => (
[
(header::CONTENT_TYPE, HeaderValue::from_static("text/html")),
(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")),
],
include_bytes!("../web/build/index.html.gz"),
)
.into_response(),
path => {
warn!("404 on path: {path}");
StatusCode::NOT_FOUND.into_response()
}
}
}
pub async fn get_config(
State(state): State<Arc<ServerState>>,
) -> Result<Json<Config>, (StatusCode, String)> {
Ok(Json(state.config.clone()))
}
pub async fn set_config(
State(state): State<Arc<ServerState>>,
Json(config): Json<Config>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
let config_str = toml::to_string_pretty(&config).map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to serialize config as TOML: {err}"),
)
})?;
write(&state.config_path, config_str).await.map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to write config file: {err}"),
)
})?;
// Trigger daemon restart after writing config
let mut restart_tx = state.daemon_restart_tx.write().await;
if let Some(sender) = restart_tx.take() {
sender.send(()).map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"couldn't send restart signal".to_string(),
)
})?;
Ok((
StatusCode::ACCEPTED,
"wrote config and triggered restart".to_string(),
))
} else {
Ok((
StatusCode::ACCEPTED,
"wrote config but restart already triggered".to_string(),
))
}
}
pub async fn get_zip(
State(state): State<Arc<ServerState>>,
Path(entry_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_idx = entry_name.trim_end_matches(".zip").to_owned();
let (entry_index, qmdl_size_bytes) = {
let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_idx).ok_or((
StatusCode::NOT_FOUND,
format!("couldn't find entry with name {qmdl_idx}"),
))?;
if entry.qmdl_size_bytes == 0 {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"QMDL file is empty, try again in a bit!".to_string(),
));
}
(entry_index, entry.qmdl_size_bytes)
};
let qmdl_store_lock = state.qmdl_store_lock.clone();
let (reader, writer) = duplex(8192);
tokio::spawn(async move {
let result: Result<(), Error> = async {
let mut zip = ZipFileWriter::with_tokio(writer);
// Add QMDL file
{
let entry =
ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored);
// FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
// not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed
// once https://github.com/Majored/rs-async-zip/pull/160 is released.
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
let mut qmdl_file = {
let qmdl_store = qmdl_store_lock.read().await;
qmdl_store
.open_entry_qmdl(entry_index)
.await?
.take(qmdl_size_bytes as u64)
};
copy(&mut qmdl_file, &mut entry_writer).await?;
entry_writer.into_inner().close().await?;
}
// Add PCAP file
{
let entry =
ZipEntryBuilder::new(format!("{qmdl_idx}.pcapng").into(), Compression::Stored);
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
let qmdl_file_for_pcap = {
let qmdl_store = qmdl_store_lock.read().await;
qmdl_store
.open_entry_qmdl(entry_index)
.await?
.take(qmdl_size_bytes as u64)
};
if let Err(e) =
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await
{
// if we fail to generate the PCAP file, we should still continue and give the
// user the QMDL.
error!("Failed to generate PCAP: {e:?}");
}
entry_writer.into_inner().close().await?;
}
zip.close().await?;
Ok(())
}
.await;
if let Err(e) = result {
error!("Error generating ZIP file: {e:?}");
}
});
let headers = [(CONTENT_TYPE, "application/zip")];
let body = Body::from_stream(ReaderStream::new(reader));
Ok((headers, body).into_response())
}
#[cfg(test)]
mod tests {
use super::*;
use async_zip::base::read::mem::ZipFileReader;
use axum::extract::{Path, State};
use tempfile::TempDir;
async fn create_test_qmdl_store() -> (TempDir, Arc<RwLock<crate::qmdl_store::RecordingStore>>) {
let temp_dir = TempDir::new().unwrap();
let store_path = temp_dir.path().to_path_buf();
let store = crate::qmdl_store::RecordingStore::create(&store_path)
.await
.unwrap();
(temp_dir, Arc::new(RwLock::new(store)))
}
async fn create_test_entry_with_data(
store_lock: &Arc<RwLock<crate::qmdl_store::RecordingStore>>,
test_data: &[u8],
) -> String {
let entry_name = {
let mut store = store_lock.write().await;
let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap();
if !test_data.is_empty() {
use tokio::io::AsyncWriteExt;
qmdl_file.write_all(test_data).await.unwrap();
qmdl_file.flush().await.unwrap();
}
let current_entry = store.current_entry.unwrap();
let entry = &store.manifest.entries[current_entry];
let entry_name = entry.name.clone();
store
.update_entry_qmdl_size(current_entry, test_data.len())
.await
.unwrap();
entry_name
};
let mut store = store_lock.write().await;
store.close_current_entry().await.unwrap();
entry_name
}
fn create_test_server_state(
store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>,
) -> Arc<ServerState> {
let (tx, _rx) = tokio::sync::mpsc::channel(1);
let (analysis_tx, _analysis_rx) = tokio::sync::mpsc::channel(1);
let analysis_status = {
let store = store_lock.try_read().unwrap();
crate::analysis::AnalysisStatus::new(&store)
};
Arc::new(ServerState {
config_path: "/tmp/test_config.toml".to_string(),
config: Config::default(),
qmdl_store_lock: store_lock,
diag_device_ctrl_sender: tx,
analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
analysis_sender: analysis_tx,
daemon_restart_tx: Arc::new(RwLock::new(None)),
})
}
#[tokio::test]
async fn test_get_zip_success() {
let (_temp_dir, store_lock) = create_test_qmdl_store().await;
let test_qmdl_data = vec![0x7E, 0x00, 0x00, 0x00, 0x10, 0x00, 0x7E];
let entry_name = create_test_entry_with_data(&store_lock, &test_qmdl_data).await;
let state = create_test_server_state(store_lock);
let result = get_zip(State(state), Path(entry_name.clone())).await;
assert!(result.is_ok());
let response = result.unwrap();
let headers = response.headers();
assert_eq!(headers.get("content-type").unwrap(), "application/zip");
let body = response.into_body();
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let zip_reader = ZipFileReader::new(body_bytes.to_vec()).await.unwrap();
let filenames = zip_reader
.file()
.entries()
.iter()
.map(|entry| entry.filename().as_str().unwrap().to_owned())
.collect::<Vec<String>>();
assert_eq!(
filenames,
vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),]
);
}
}

View File

@@ -7,6 +7,7 @@ use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use log::error;
use rayhunter::util::RuntimeMetadata;
use serde::Serialize;
use tokio::process::Command;
@@ -14,6 +15,7 @@ use tokio::process::Command;
pub struct SystemStats {
pub disk_stats: DiskStats,
pub memory_stats: MemoryStats,
pub runtime_metadata: RuntimeMetadata,
}
impl SystemStats {
@@ -21,6 +23,7 @@ impl SystemStats {
Ok(Self {
disk_stats: DiskStats::new(qmdl_path).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
async fn get_cmd_output(mut cmd: Command) -> Result<String, String> {
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))?;
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())
}
@@ -79,7 +88,8 @@ impl MemoryStats {
let mut free_cmd = Command::new("free");
free_cmd.arg("-k");
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>());
Ok(Self {
total: humanize_kb(numbers.next().ok_or("error parsing free output")?),
@@ -91,23 +101,25 @@ impl MemoryStats {
// turns a number of kilobytes (like 28293) into a human-readable string (like "28.3M")
fn humanize_kb(kb: usize) -> String {
if kb < 1000{
return format!("{}K", kb);
if kb < 1000 {
return format!("{kb}K");
}
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;
match SystemStats::new(qmdl_store.path.to_str().unwrap()).await {
Ok(stats) => Ok(Json(stats)),
Err(err) => {
error!("error getting system stats: {}", err);
error!("error getting system stats: {err}");
Err((
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 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 mut entries = qmdl_store.manifest.entries.clone();
let current_entry = qmdl_store.current_entry.map(|index| entries.remove(index));

24
daemon/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
daemon/web/.npmrc Normal file
View File

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

View File

@@ -0,0 +1,7 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
# Static Assets
static/pico.min.css

15
daemon/web/.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

View File

@@ -0,0 +1,42 @@
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(
{
ignores: ['build/', '.svelte-kit/**', 'dist/'],
},
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,
},
},
},
{
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
},
}
);

38
daemon/web/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "web",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build && gzip -9 ./build/index.html",
"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 .",
"fix": "eslint --fix ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.13.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
daemon/web/src/app.css Normal file
View File

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

13
daemon/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
daemon/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,140 @@
import { describe, it, expect } from 'vitest';
import { AnalysisRowType, EventType, parse_finished_report, Severity } from './analysis.svelte';
import { type NewlineDeliminatedJson } from './ndjson';
const SAMPLE_V1_REPORT_NDJSON: NewlineDeliminatedJson = [
{
analyzers: [
{
name: 'Analyzer 1',
description: 'A first analyzer',
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
},
],
},
{
timestamp: '2024-10-08T13:25:43.011689003-07:00',
skipped_message_reasons: ['The reason why the message was skipped'],
analysis: [],
},
{
timestamp: '2024-10-08T13:25:43.480872496-07:00',
skipped_message_reasons: [],
analysis: [
{
timestamp: '2024-08-19T03:33:54.318Z',
events: [
null,
{
event_type: { type: 'QualitativeWarning', severity: 'Low' },
message: 'Something nasty happened',
},
],
},
],
},
];
const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
{
analyzers: [
{
name: 'Analyzer 1',
description: 'A first analyzer',
version: 2,
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
version: 2,
},
],
report_version: 2,
},
{
skipped_message_reason: 'The reason why the message was skipped',
},
{
packet_timestamp: '2024-08-19T03:33:54.318Z',
events: [
null,
{
event_type: { type: 'QualitativeWarning', severity: 'Low' },
message: 'Something nasty happened',
},
],
},
];
describe('analysis report parsing', () => {
it('parses v1 example analysis', () => {
const report = parse_finished_report(SAMPLE_V1_REPORT_NDJSON);
expect(report.metadata.report_version).toEqual(1);
expect(report.metadata.analyzers).toEqual([
{
name: 'Analyzer 1',
description: 'A first analyzer',
version: 0,
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
version: 0,
},
]);
expect(report.rows).toHaveLength(2);
expect(report.rows[0].type).toBe(AnalysisRowType.Skipped);
if (report.rows[1].type === AnalysisRowType.Analysis) {
const row = report.rows[1];
expect(row.events).toHaveLength(2);
expect(row.events[0]).toBeNull();
const event = row.events[1];
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
if (event !== null && event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
} else {
throw 'wrong row type';
}
});
it('parses v2 example analysis', () => {
const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON);
expect(report.metadata.report_version).toEqual(2);
expect(report.metadata.analyzers).toEqual([
{
name: 'Analyzer 1',
description: 'A first analyzer',
version: 2,
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
version: 2,
},
]);
expect(report.rows).toHaveLength(2);
expect(report.rows[0].type).toBe(AnalysisRowType.Skipped);
if (report.rows[1].type === AnalysisRowType.Analysis) {
const row = report.rows[1];
expect(row.events).toHaveLength(2);
expect(row.events[0]).toBeNull();
const event = row.events[1];
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
if (event !== null && event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
} else {
throw 'wrong row type';
}
});
});

View File

@@ -0,0 +1,208 @@
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 class ReportMetadata {
public analyzers: AnalyzerMetadata[];
public rayhunter: RayhunterMetadata;
public report_version: number;
constructor(ndjson: any) {
this.analyzers = ndjson.analyzers;
this.rayhunter = ndjson.rayhunter;
if (ndjson.report_version === undefined) {
this.report_version = 1;
// we consider our legacy (unversioned) heuristics to be v0 --
// this'll let us clearly differentiate some known false-positive
// results from the pre-versioned era from v1 heuristics
this.analyzers.forEach((analyzer) => {
analyzer.version = 0;
});
} else {
this.report_version = ndjson.report_version;
}
}
}
export type RayhunterMetadata = {
rayhunter_version: string;
system_os: string;
arch: string;
};
export type AnalyzerMetadata = {
name: string;
description: string;
version: number;
};
export type AnalysisRow = SkippedPacket | PacketAnalysis;
export enum AnalysisRowType {
Skipped,
Analysis,
}
export type SkippedPacket = {
type: AnalysisRowType.Skipped;
reason: string;
};
export type PacketAnalysis = {
type: AnalysisRowType.Analysis;
packet_timestamp: Date;
events: Event[];
};
export type Event = QualitativeWarning | InformationalEvent | null;
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;
};
function get_event(event_json: any): Event {
if (event_json.event_type.type === 'Informational') {
return {
type: EventType.Informational,
message: event_json.message,
};
} else {
return {
type: EventType.Warning,
severity:
event_json.event_type.severity === 'High'
? Severity.High
: event_json.event_type.severity === 'Medium'
? Severity.Medium
: Severity.Low,
message: event_json.message,
};
}
}
function get_v1_rows(row_jsons: any[]): AnalysisRow[] {
const rows: AnalysisRow[] = [];
for (const row_json of row_jsons) {
for (const reason of row_json.skipped_message_reasons) {
rows.push({
type: AnalysisRowType.Skipped,
reason,
});
}
for (const analysis_json of row_json.analysis) {
const events: Event[] = analysis_json.events.map((event_json: any): Event | null => {
if (event_json === null) {
return null;
} else {
return get_event(event_json);
}
});
rows.push({
type: AnalysisRowType.Analysis,
packet_timestamp: new Date(analysis_json.timestamp),
events,
});
}
}
return rows;
}
function get_v2_rows(row_jsons: any[]): AnalysisRow[] {
const rows: AnalysisRow[] = [];
for (const row_json of row_jsons) {
if (row_json.skipped_message_reason) {
rows.push({
type: AnalysisRowType.Skipped,
reason: row_json.skipped_message_reason,
});
} else {
const events: Event[] = row_json.events.map((event_json: any): Event | null => {
if (event_json === null) {
return null;
} else {
return get_event(event_json);
}
});
rows.push({
type: AnalysisRowType.Analysis,
packet_timestamp: new Date(row_json.packet_timestamp),
events,
});
}
}
return rows;
}
function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
let num_warnings = 0;
let num_informational_logs = 0;
let num_skipped_packets = 0;
for (const row of rows) {
if (row.type === AnalysisRowType.Skipped) {
num_skipped_packets++;
} else {
for (const event of row.events) {
if (event !== null) {
if (event.type === EventType.Informational) {
num_informational_logs++;
} else {
num_warnings++;
}
}
}
}
}
return {
num_warnings,
num_informational_logs,
num_skipped_packets,
};
}
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
const metadata = new ReportMetadata(report_json[0]);
let rows;
if (metadata.report_version === 1) {
rows = get_v1_rows(report_json.slice(1));
} else {
rows = get_v2_rows(report_json.slice(1));
}
const statistics = get_report_stats(rows);
return {
statistics,
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,64 @@
import { get_report, type AnalysisReport } from './analysis.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,64 @@
<script lang="ts">
import { AnalysisStatus } from '$lib/analysisManager.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 {
return `${entry.analysis_report.statistics.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() || 0) < 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,107 @@
<script lang="ts">
import { AnalysisRowType, EventType, type AnalysisReport } from '$lib/analysis.svelte';
let {
report,
}: {
report: AnalysisReport;
} = $props();
const date_formatter = new Intl.DateTimeFormat(undefined, {
timeStyle: 'long',
dateStyle: 'short',
});
const analyzers = report.metadata.analyzers;
const skipped_messages: Map<string, number> = $derived.by(() => {
let map = new Map();
for (const row of report.rows) {
if (row.type === AnalysisRowType.Skipped) {
let count = map.get(row.reason);
if (count === undefined) {
count = 0;
}
map.set(row.reason, 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}
<div class="overflow-x-scroll">
<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">Heuristic</th>
<th class="p-2">Warning</th>
<th class="p-2">Severity</th>
</tr>
</thead>
<tbody>
{#each report.rows as row}
{#if row.type === AnalysisRowType.Analysis}
{@const parsed_date = new Date(row.packet_timestamp)}
{#each row.events.filter((e) => e !== null) as event, i}
{@const analyzer = analyzers[i]}
<tr class="even:bg-gray-200 odd:bg-white">
{#if event.type === EventType.Warning}
{@const severity = ['Low', 'Medium', 'High'][
event.severity
]}
{@const severity_class = [
'bg-red-200',
'bg-red-400',
'bg-red-600',
][event.severity]}
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2 {severity_class} text-center">{severity}</td>
{:else if event.type === EventType.Informational}
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2">Info</td>
{/if}
</tr>
{/each}
{/if}
{/each}
</tbody>
</table>
</div>
{/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>
<div class="overflow-x-scroll">
<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>
</div>
{/if}

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { type ReportMetadata } from '$lib/analysis.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte';
import AnalysisTable from './AnalysisTable.svelte';
let {
entry,
}: {
entry: ManifestEntry;
} = $props();
</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,258 @@
<script lang="ts">
import { get_config, set_config, type Config } from '../utils.svelte';
let config = $state<Config | null>(null);
let loading = $state(false);
let saving = $state(false);
let message = $state('');
let messageType = $state<'success' | 'error' | null>(null);
let showConfig = $state(false);
async function loadConfig() {
try {
loading = true;
config = await get_config();
message = '';
messageType = null;
} catch (error) {
message = `Failed to load config: ${error}`;
messageType = 'error';
} finally {
loading = false;
}
}
async function saveConfig() {
if (!config) return;
try {
saving = true;
await set_config(config);
message =
'Config saved successfully! Rayhunter is restarting now. Reload the page in a few seconds.';
messageType = 'success';
} catch (error) {
message = `Failed to save config: ${error}`;
messageType = 'error';
} finally {
saving = false;
}
}
// Load config when first shown
$effect(() => {
if (showConfig && !config) {
loadConfig();
}
});
</script>
<div class="bg-white rounded-lg shadow-md p-6 m-4">
<button
class="w-full flex justify-between items-center text-xl font-bold mb-4 text-rayhunter-dark-blue hover:text-rayhunter-blue"
onclick={() => (showConfig = !showConfig)}
>
<span>Configuration</span>
<svg
class="w-6 h-6 transition-transform {showConfig ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
{#if showConfig}
{#if loading}
<div class="text-center py-4">Loading config...</div>
{:else if config}
<form
class="space-y-4"
onsubmit={(e) => {
e.preventDefault();
saveConfig();
}}
>
<div>
<label for="ui_level" class="block text-sm font-medium text-gray-700 mb-1">
Device UI Level
</label>
<select
id="ui_level"
bind:value={config.ui_level}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
>
<option value={0}>0 - Invisible mode</option>
<option value={1}>1 - Subtle mode (colored line)</option>
<option value={2}>2 - Demo mode (orca gif)</option>
<option value={3}>3 - EFF logo</option>
</select>
</div>
<div>
<label
for="key_input_mode"
class="block text-sm font-medium text-gray-700 mb-1"
>
Device Input Mode
</label>
<select
id="key_input_mode"
bind:value={config.key_input_mode}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
>
<option value={0}>0 - Disable button control</option>
<option value={1}
>1 - Double-tap power button to start/stop recording</option
>
</select>
</div>
<div class="space-y-3">
<div class="flex items-center">
<input
id="colorblind_mode"
type="checkbox"
bind:checked={config.colorblind_mode}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="colorblind_mode" class="ml-2 block text-sm text-gray-700">
Colorblind Mode
</label>
</div>
</div>
<div class="border-t pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
Analyzer Heuristic Settings
</h3>
<div class="space-y-3">
<div class="flex items-center">
<input
id="imsi_requested"
type="checkbox"
bind:checked={config.analyzers.imsi_requested}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
IMSI Requested Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="connection_redirect_2g_downgrade"
type="checkbox"
bind:checked={config.analyzers.connection_redirect_2g_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label
for="connection_redirect_2g_downgrade"
class="ml-2 block text-sm text-gray-700"
>
Connection Redirect 2G Downgrade Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="lte_sib6_and_7_downgrade"
type="checkbox"
bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label
for="lte_sib6_and_7_downgrade"
class="ml-2 block text-sm text-gray-700"
>
LTE SIB6 and SIB7 Downgrade Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="null_cipher"
type="checkbox"
bind:checked={config.analyzers.null_cipher}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="null_cipher" class="ml-2 block text-sm text-gray-700">
Null Cipher Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="nas_null_cipher"
type="checkbox"
bind:checked={config.analyzers.nas_null_cipher}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
NAS Null Cipher Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="incomplete_sib"
type="checkbox"
bind:checked={config.analyzers.incomplete_sib}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
Incomplete SIB Heuristic
</label>
</div>
</div>
</div>
<div class="flex gap-2 pt-4">
<button
type="submit"
disabled={saving}
class="bg-blue-500 hover:bg-blue-700 disabled:opacity-50 text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1 items-center"
>
{#if saving}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
Saving...
{:else}
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
Apply and restart
{/if}
</button>
</div>
</form>
{#if message}
<div
class="mt-4 p-3 rounded {messageType === 'error'
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'}"
>
{message}
</div>
{/if}
{:else}
<div class="text-center py-4 text-red-600">
Failed to load configuration. Please try reloading the page.
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import DeleteButton from './DeleteButton.svelte';
</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,32 @@
<script lang="ts">
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-2 sm: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,27 @@
<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-2 sm:px-4 rounded-md'
: 'text-blue-600 underline'}"
onclick={download}
>
{text}
<svg class="fill-current w-4 h-4 m-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M13 8V2H7v6H2l8 8 8-8h-5zM0 18h20v2H0v-2z" />
</svg>
</button>

View File

@@ -0,0 +1,97 @@
<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,
server_is_recording,
}: {
entry: ManifestEntry;
current: boolean;
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 overflow-x-scroll overflow-y-hidden"
>
{#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}
{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}
{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: {(entry.last_message_time &&
date_formatter.format(entry.last_message_time)) ||
'N/A'}</span
>
</div>
<div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-scroll">
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button />
<DownloadLink url={entry.get_zip_url()} text="zip" full_button />
{#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,38 @@
<script lang="ts">
import { 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">ZIP</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}
<Card {entry} current={false} {server_is_recording} />
{/each}
</div>

View File

@@ -0,0 +1,64 @@
<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"
>{(entry.last_message_time && date_formatter.format(entry.last_message_time)) || 'N/A'}</td
>
<td class="p-2">{entry.get_readable_qmdl_size()}</td>
<td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
<td class="p-2"><DownloadLink url={entry.get_zip_url()} text="zip" /></td>
<td class="p-2"
><AnalysisStatus onclick={toggle_analysis_visibility} {entry} {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="9">
<AnalysisView {entry} />
</td>
</tr>

View File

@@ -0,0 +1,100 @@
<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-2 sm:px-4 rounded-md flex flex-row gap-1';
const stop_recording_classes = `${recording_button_classes} bg-red-500 opacity-50 cursor-not-allowed`;
const start_recording_classes = `${recording_button_classes} bg-blue-500 opacity-50 cursor-not-allowed`;
</script>
<div>
{#if waiting_for_server}
<button
class={server_is_recording ? stop_recording_classes : start_recording_classes}
disabled
>
<span>{server_is_recording ? 'Stopping...' : 'Starting...'}</span>
<svg
class="w-4 h-4 text-white animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
{:else if server_is_recording}
<button
class="{recording_button_classes} bg-red-500 hover:bg-red-700"
onclick={stop_recording}
>
<span>Stop</span>
<svg
class="w-6 h-6 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z" />
</svg>
</button>
{: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,37 @@
<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>

View File

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

View File

@@ -0,0 +1,105 @@
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;
}
export class Manifest {
public entries: ManifestEntry[] = [];
public current_entry: ManifestEntry | undefined;
constructor(json: JsonManifest) {
for (const 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 (const 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.start_time = new Date(json.start_time);
if (json.last_message_time) {
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;
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}.pcapng`;
}
get_qmdl_url(): string {
return `/api/qmdl/${this.name}.qmdl`;
}
get_zip_url(): string {
return `/api/zip/${this.name}.zip`;
}
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();
});
});

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,58 @@
import { Manifest } from './manifest.svelte';
import type { SystemStats } from './systemStats';
export interface AnalyzerConfig {
imsi_requested: boolean;
connection_redirect_2g_downgrade: boolean;
lte_sib6_and_7_downgrade: boolean;
null_cipher: boolean;
nas_null_cipher: boolean;
incomplete_sib: boolean;
}
export interface Config {
ui_level: number;
colorblind_mode: boolean;
key_input_mode: number;
analyzers: AnalyzerConfig;
}
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'));
}
export async function get_config(): Promise<Config> {
return JSON.parse(await req('GET', '/api/config'));
}
export async function set_config(config: Config): Promise<void> {
const response = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
}

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,138 @@
<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';
import ConfigForm from '$lib/components/ConfigForm.svelte';
let manager: AnalysisManager = new AnalysisManager();
let loaded = $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;
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">
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
<img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" />
<div class="flex flex-row gap-4">
<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 current_entry}
<Card entry={current_entry} current={true} server_is_recording={!!current_entry} />
{: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 server_is_recording={!!current_entry} />
</div>
</div>
{/if}
<SystemStatsTable stats={system_stats!} />
</div>
<div class="flex flex-col gap-2">
<span class="text-xl">History</span>
<ManifestTable {entries} server_is_recording={!!current_entry} />
</div>
<DeleteAllButton />
<ConfigForm />
{:else}
<div class="flex flex-col justify-center items-center">
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
<img src="/rayhunter_orca_only.png" alt="" class="h-48 animate-spin" />
<p class="text-xl">Loading...</p>
</div>
{/if}
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

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

View File

@@ -0,0 +1,26 @@
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,
}),
output: {
// Force everything into one HTML file. SvelteKit will still generate
// a lot of JS files but they are deadweight and will not be included
// in the rust binary.
bundleStrategy: 'inline',
},
version: {
// Use a deterministic version string for reproducible builds.
// Without this option, SvelteKit will use a timestamp.
name: process.env.GITHUB_SHA || 'dev',
},
},
};

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
daemon/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
}

39
daemon/web/vite.config.ts Normal file
View File

@@ -0,0 +1,39 @@
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()],
build: {
// Force everything into one HTML file. SvelteKit will still generate
// a lot of JS files but they are deadweight and will not be included
// in the rust binary.
assetsInlineLimit: Infinity,
},
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
},
});

View File

@@ -1,10 +0,0 @@
# cat config.toml
qmdl_store_path = "/data/rayhunter/qmdl"
port = 8080
readonly_mode = false
# UI Levels:
# 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
# 2 = Demo Mode, display a fun orca gif
# 3 = display the EFF logo
ui_level = 1

34
dist/config.toml.in vendored Normal file
View File

@@ -0,0 +1,34 @@
# cat config.toml
qmdl_store_path = "/data/rayhunter/qmdl"
port = 8080
debug_mode = false
colorblind_mode = false
# Device selection. This will be overwritten by the installer. Defaults to "orbic".
#device = "orbic"
# UI Levels:
#
# Orbic and TP-Link with color display:
# 0 = invisible mode, no indicator that 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
# 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
# 0 = rayhunter does not read button presses
# 1 = double-tapping the power button starts/stops recordings
key_input_mode = 0
# Analyzer Configuration
# Enable/disable specific IMSI catcher detection heuristics
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
[analyzers]
imsi_requested = true
connection_redirect_2g_downgrade = true
lte_sib6_and_7_downgrade = true
null_cipher = true
nas_null_cipher = true
incomplete_sib = true

View File

@@ -1,96 +0,0 @@
#!/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
check_adb
force_debug_mode
setup_rootshell
setup_rayhunter
test_rayhunter
}
check_adb() {
if ! command -v adb &> /dev/null
then
echo "adb not found, please ensure it's installed or check the README.md"
exit 1
fi
}
force_debug_mode() {
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!"
}
wait_for_adb_shell() {
until adb shell true 2> /dev/null
do
echo -n .
sleep 1
done
echo
}
setup_rootshell() {
_adb_push rootshell /tmp/
"$SERIAL_PATH" "AT+SYSCMD=cp /tmp/rootshell /bin/rootshell"
sleep 1
"$SERIAL_PATH" "AT+SYSCMD=chown root /bin/rootshell"
sleep 1
"$SERIAL_PATH" "AT+SYSCMD=chmod 4755 /bin/rootshell"
echo "we have root!"
adb shell /bin/rootshell -c id
}
_adb_push() {
adb push "$(dirname "$0")/$1" "$2"
}
setup_rayhunter() {
adb shell '/bin/rootshell -c "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
adb shell '/bin/rootshell -c "cp /tmp/rayhunter_daemon /etc/init.d/rayhunter_daemon"'
adb shell '/bin/rootshell -c "cp /tmp/misc-daemon /etc/init.d/misc-daemon"'
adb shell '/bin/rootshell -c "chmod 755 /etc/init.d/rayhunter_daemon"'
adb shell '/bin/rootshell -c "chmod 755 /etc/init.d/misc-daemon"'
echo -n "rebooting, this may take a sec..."
adb shell '/bin/rootshell -c reboot'
# first wait for shutdown (it can take ~10s)
until ! adb shell true 2> /dev/null
do
echo -n '.'
sleep 1
done
# now wait for boot to finish
wait_for_adb_shell
echo "rebooted successfully!"
}
test_rayhunter() {
URL="http://localhost:8080"
adb forward tcp:8080 tcp:8080
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
echo "success! you can access rayhunter at $URL"
return
fi
sleep 1
echo -n "."
done
echo "timeout reached! failed to reach rayhunter url $URL, something went wrong :("
}

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