472 Commits

Author SHA1 Message Date
Cooper Quintin
69dc528f34 bump version to 0.6.1 2025-08-25 22:28:10 +02:00
Cooper Quintin
29ce6729ee fix readme links 2025-08-25 11:23:34 -07:00
Cooper Quintin
5919a19aba revert removing link 2025-08-25 09:29:28 -07:00
Cooper Quintin
35ca590e46 improvements to documentation 2025-08-25 09:29:28 -07:00
Sashanoraa
56122f6559 Add error reporting to the daemon web UI
This error reporting comes in two forms:
- Errors updating the UI
- Errors with user actions

The former is displayed as one error until a refresh succeeds again. The
latter creates an number of persistent errors until they are cleared by
the user.
2025-08-25 03:15:25 -04:00
Cooper Quintin
bbab29ae0b Update lib/src/analysis/imsi_requested.rs
Co-authored-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net>
2025-08-22 15:37:18 -07:00
Cooper Quintin
2a620fd1fb cargo fmt 2025-08-22 15:37:18 -07:00
Cooper Quintin
515bb40a76 fix false positive on identity requested without attach described in #557 2025-08-22 15:37:18 -07:00
Cooper Quintin
a5ec1c9505 more verbosity 2025-08-22 23:55:40 +02:00
Markus Unterwaditzer
806bd62a0e Document reanalyzing feature, and include rayhunter-check in release 2025-08-22 23:55:40 +02:00
Cooper Quintin
6ceced2d31 bump version to 0.6.0 2025-08-20 17:58:45 -07:00
Cooper Quintin
856374c05a revert framerate change 2025-08-20 17:11:04 -07:00
Cooper Quintin
983867c2a6 make web ui reflect screen ui colors 2025-08-20 17:11:04 -07:00
Cooper Quintin
145d0a295a cargo fmt 2025-08-20 17:11:04 -07:00
Cooper Quintin
c021b9150d slight refactor 2025-08-20 17:11:04 -07:00
Cooper Quintin
ce916dcd10 display colors as well as patterns for severity 2025-08-20 17:11:04 -07:00
Cooper Quintin
898bdbb6cd include event severity in message 2025-08-20 17:11:04 -07:00
Markus Unterwaditzer
375789aad9 cargo fmt 2025-08-20 17:11:04 -07:00
Cooper Quintin
85f7b2cc81 refactor test analyzer for new format 2025-08-20 17:11:04 -07:00
Markus Unterwaditzer
781d11ed72 Expose severity to display
See https://github.com/EFForg/rayhunter/issues/334

Severity levels low, medium, high are now exposed to the UI in form of
dotted, dashed and solid lines. The line on the UI represents the
highest-so-far severity seen.

Originally this was intended to be represented by Yellow/Orange/Red, but
this would mean yet another divergence for colorblind mode. This is
colorblind-friendly by default (I think...)

As part of this, simplify EventType so that it becomes a flat "level"
enum without nested variants.

There is also a new debug endpoint that allows one to overwrite the
display level directly for testing.
2025-08-20 17:11:04 -07:00
Cooper Quintin
6927da49b4 cargo fmt 2025-08-20 14:48:47 -07:00
Cooper Quintin
479505f738 appease clippy 2025-08-20 14:48:47 -07:00
Cooper Quintin
468b07faf0 proper formatting for CID and PLMN 2025-08-20 14:48:47 -07:00
Cooper Quintin
493fdfa227 add docs 2025-08-20 14:48:47 -07:00
Cooper Quintin
ffdad4aed8 add test analyzer 2025-08-20 14:48:47 -07:00
Markus Unterwaditzer
33e4fbc544 Fix wrong analyzer name in AnalysisTable
Each event index corresponds to an index in analyzers. But some events
may be null. We're skipping those events without incrementing the index,
leading to wrong analyzer names.
2025-08-20 14:39:09 -07:00
Markus Unterwaditzer
8c510b43c9 Merge pull request #429 from simonft/notifications 2025-08-20 21:56:45 +02:00
Markus Unterwaditzer
46850e2739 Update installer/src/tplink.rs
Co-authored-by: Cooper Quintin <cooperq@users.noreply.github.com>
2025-08-20 09:53:10 -07:00
Markus Unterwaditzer
53e3b8ee34 Mention required SD card for tplink 2025-08-20 09:53:10 -07:00
Markus Unterwaditzer
0fc51d79f4 Fix issue template and improve one error message
The current error message is not very useful for remote-debugging. Add
enough context to allow technically adept users to figure out a way.

See #544
2025-08-20 09:53:10 -07:00
Markus Unterwaditzer
ad4e971e77 Restyle download links in table view
They're taking up too much space as individual columns.

Also change DownloadLink to be a real link, so that "Copy URL" works.
2025-08-20 09:48:37 -07:00
Markus Unterwaditzer
c5a79e545d Add documentation for Orbic Network installer 2025-08-20 09:41:43 -07:00
Markus Unterwaditzer
9d92ab3c01 Simplify remounting procedure 2025-08-19 17:36:10 -07:00
Markus Unterwaditzer
cf254b66ff Address review comments and update wingtech docs 2025-08-19 17:36:10 -07:00
Markus Unterwaditzer
cddc590c77 Try to use the same rust version as in other jobs 2025-08-19 17:36:10 -07:00
Markus Unterwaditzer
9d736f5bf0 Add a orbic network installer
There is a shell injection vulnerability after all, so we can just
launch a remote shell, tplink-style. Except there's no telnetd on this
device so we need to use netcat.

This was found in the goahead binary on the device using Ghidra. The
decompiled code for this endpoint looks like this:

```c
void FUN_0003c614(int param_1)

{
  int iVar1;
  undefined4 uVar2;
  int local_160;
  undefined1 auStack_15c [64];
  char acStack_11c [256];
  int local_1c;

  local_1c = __stack_chk_guard;
  if (param_1 == 0) {
    error("input parameter is NULL!");
    uVar2 = 0x66;
    goto LAB_0003c808;
  }
  iVar1 = websGetJsonItemValue(param_1,"password",10,auStack_15c,0x40);
  if (iVar1 != 0) {
    iVar1 = get_log_level_something();
    if (1 < iVar1) {
      some_logging_func(2,"modifying root password(%s)...",auStack_15c);
    }
    iVar1 = sprintf(acStack_11c,"echo root:\"%s\"|chpasswd",auStack_15c);
    acStack_11c[iVar1] = '\0';
    system(acStack_11c);
  }
```

Usage is `./installer orbic-network`, as an alternative to `./installer
orbic`. It should work on Windows without any kind of drivers.

This installer also works on the Moxee device.
2025-08-19 17:36:10 -07:00
oopsbagel
e5df43d7f5 Merge pull request #520 from simonft/update-install-commands-in-doc
Update instructions for installing from source
2025-08-13 20:22:33 +00:00
Simon Fondrie-Teitler
a8667cc3a0 Pass notification_channel in diag_task constructor 2025-08-11 13:59:42 -04:00
Markus Unterwaditzer
3239daa011 Merge pull request #529 from EFForg/macos-intel-ci
Move MacOS intel builds to macos-latest
2025-08-11 16:50:14 +02:00
Markus Unterwaditzer
651511cc63 Merge pull request #530 from EFForg/windows-installer
Rename windows installer file to installer.exe
2025-08-11 16:50:02 +02:00
Simon Fondrie-Teitler
211066ec7b Record last attempt time when notification connection fails 2025-08-09 15:18:04 -04:00
Simon Fondrie-Teitler
16ec9e28df Remove a couple unnecssary whitespace changes 2025-08-09 15:03:08 -04:00
Simon Fondrie-Teitler
4462f02c10 Properly close notification service channel 2025-08-09 14:55:13 -04:00
Simon Fondrie-Teitler
5bd2d9a58e Merge branch 'main' into notifications 2025-08-09 14:17:22 -04:00
Markus Unterwaditzer
603d65a3bd Move MacOS intel builds to macos-latest
We're now using ARM runners which crosscompile to Intel.

Fix #525
2025-08-08 21:53:20 +02:00
Markus Unterwaditzer
c0a9cf62df Rename windows installer file to installer.exe
Fix #521
2025-08-08 21:53:02 +02:00
Markus Unterwaditzer
0a20e659be Merge pull request #527 from EFForg/fix-rust-warnings
Fix clippy lints and warnings in Rust 1.89
2025-08-08 21:52:46 +02:00
Markus Unterwaditzer
ce599dc432 specify package.rust-version for daemon 2025-08-08 18:02:40 +02:00
Markus Unterwaditzer
85b50bc301 Remove unpack! macro 2025-08-08 03:12:38 +02:00
Markus Unterwaditzer
5249714717 Fix clippy lints and warnings in Rust 1.89
This will also require Rust 1.89 due to if-let.
2025-08-08 03:02:07 +02:00
Simon Fondrie-Teitler
67974264f9 Update Config object in svelte to add ntfy url 2025-08-06 13:12:10 -04:00
Simon Fondrie-Teitler
f562d33be3 Document notification configuration 2025-08-06 13:08:16 -04:00
Simon Fondrie-Teitler
0531aa0e3a Require rust 1.88 so we can use let chains 2025-08-06 13:08:08 -04:00
Simon Fondrie-Teitler
dd78f5007d Specify ntfy notification is from rayhunter 2025-08-06 13:07:25 -04:00
Simon Fondrie-Teitler
1c08708bc4 Set full ntfy url in config instead of just topic 2025-08-06 13:07:08 -04:00
Simon Fondrie-Teitler
0f53da58bc Better document options when installing from source 2025-08-06 10:55:05 -04:00
Simon Fondrie-Teitler
01010df4ec Update instructions for installing from source
Command changed in #460
2025-08-05 17:59:13 -04:00
Simon Fondrie-Teitler
481f02f81f Merge branch 'main' into notifications 2025-08-05 17:30:07 -04:00
Markus Unterwaditzer
8c67a92b07 Merge pull request #511 from Tunas1337/uz801
Add UZ801 support
2025-08-05 21:23:27 +02:00
Markus Unterwaditzer
31bd60dea1 cargo fmt 2025-08-05 21:11:17 +02:00
Markus Unterwaditzer
13877f7209 cargo clippy 2025-08-05 21:05:31 +02:00
Andrej
f4522dbe3d cargo fmt run 2025-08-05 14:21:19 -04:00
Matej Kovacic
30bb18016e Update heuristics.md 2025-08-05 08:17:51 -07:00
Matej Kovacic
c6aa53acd2 Update heuristics.md
More explanation of logic behind analysers.
2025-08-05 08:17:51 -07:00
oopsbagel
c6882ed173 Merge pull request #515 from EFForg/installer-faster
Speed up file transfer for network-based installers
2025-08-05 00:30:46 +00:00
Markus Unterwaditzer
5c03f6ea03 Add reanalyze button
Add a reanalyze button for individual recordings in the analysis dropdown

As part of this, split out ApiRequestButton so that state transitions
(clickable -> loading/disabled -> done) can be shared across start/stop
recording and this new button. Other buttons might benefit from this as
well.

Also fix a broken checkbox while we're here.
2025-08-04 17:19:42 -07:00
Andrej
5184c6138d uz801: Add uninstalling instructions 2025-08-04 13:23:14 -04:00
Andrej
c893f8e2a9 uz801: update docs 2025-08-04 13:16:46 -04:00
Andrej
2e6343c343 uz801: clippy fixes 2025-08-04 11:35:23 -04:00
Andrej
da4a86be13 uz801: Installer improvements 2025-08-04 11:33:37 -04:00
Andrej
55794cbdd5 uz801: Fix clippy warnings 2025-08-03 20:03:01 -04:00
Andrej
e36b490d15 uz801: installer: Add file transfer verify, other fixes 2025-08-03 19:27:25 -04:00
Andrej
574e897610 cargo fmt pass 2025-08-03 18:49:38 -04:00
Andrej
1f19bc880f uz801: Force LED updates every 5s to beat MifiService 2025-08-03 18:35:54 -04:00
Markus Unterwaditzer
8dc6206683 Speed up file transfer for network-based installers
There is a bug in `telnet_send_file` where we never close the connection
to nc, and instead wait for it to time out.

This means every file transfer takes at least 5 seconds.
2025-08-04 00:06:57 +02:00
Andrej
7184ccd5c1 uz801: installer: Update VID/PID, add remount 2025-08-03 18:06:13 -04:00
Andrej
cb22e179d6 uz801: installer: Add missing dependency 2025-08-03 17:30:15 -04:00
Andrej
a3db5029ad uz801: Update installer 2025-08-03 16:51:46 -04:00
Andrej
9f661ab398 uz801: Update documentation 2025-08-03 16:51:36 -04:00
Andrej
412ad3d8bf cargo fmt run 2025-08-03 16:29:59 -04:00
Andrej
4d2d49326a uz801: Update installer usb backdoor trigger code 2025-08-03 11:12:46 -04:00
Andrej
c26ad29ffb uz801: Fix installer defaults, fix docs for admin IP 2025-08-03 11:05:50 -04:00
Andrej
f57fc611c2 uz801: Use busybox coreutils (they were hiding all along) 2025-08-03 10:54:47 -04:00
Andrej
38a408757a Add uz801 to supported devices list 2025-08-03 10:28:43 -04:00
Andrej
0540504eea uz801: Correct LED definition comment 2025-08-03 10:25:56 -04:00
Andrej
28a0c06017 uz801: Add purchase links to documentation 2025-08-03 00:51:35 -04:00
Andrej
6141087f9d uz801: Added docs 2025-08-03 00:41:40 -04:00
Andrej
7a053a4f89 uz801: cargo fmt run 2025-08-02 21:03:28 -04:00
Andrej
6473c05e3e uz801: Refactor strings, since &&'static str can be coerced into &dyn AsRef<str>: & 2025-08-02 20:59:09 -04:00
Andrej
c697773244 uz801: Add initial (experimental) support 2025-08-02 20:58:19 -04:00
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
f55d9128d4 Merge branch 'main' into notifications 2025-06-27 17:25:01 -04: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
Simon Fondrie-Teitler
84534bbb2c Clippy fixes 2025-06-27 13:56:30 -04:00
Simon Fondrie-Teitler
1d50440c85 Use RustCrypto TLS provider so we don't need to depend on LLVM 2025-06-27 11:44:35 -04:00
Simon Fondrie-Teitler
2c05f3d94e Add notifications with ntfy 2025-06-26 18:01:29 -04: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
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
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
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
184 changed files with 33766 additions and 9774 deletions

View File

@@ -1,11 +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"]
# optimizations to reduce the binary size
[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]
strip = true
# keep line numbers in stack traces for non-firmware binaries
debug = "limited"
lto = "fat"
opt-level = "z"
lto = true
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

View File

@@ -1 +1,2 @@
c5bbaabe15d4ccfee97b9997a13569fbfea13c45
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

View File

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

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: Rayhunter Mattermost
url: https://opensource.eff.org/signup_user_complete/?id=6iqur37ucfrctfswrs14iscobw&md=link&sbr=su

View File

@@ -1,6 +1,5 @@
name: Feature Request
description: Suggest a new feature or improvement to Rayhunter
title: "[Feature Request]: "
labels: ["enhancement"]
body:
- type: textarea

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 M7350
- Tplink M7310
- 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

View File

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

View File

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

View File

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

View File

@@ -1,118 +0,0 @@
name: Build Release
on:
push:
branches: [main, "release-*"]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
build_serial_and_check:
strategy:
matrix:
platform:
- name: ubuntu-24
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- name: ubuntu-24-aarch64
os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
- name: macos-arm
os: macos-latest
target: aarch64-apple-darwin
- name: macos-intel
os: macos-13
target: x86_64-apple-darwin
- name: windows-x86_64
os: windows-latest
target: x86_64-pc-windows-gnu
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- name: Build serial
run: cargo build --bin serial --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
name: serial-${{ matrix.platform.name }}
path: target/${{ matrix.platform.target }}/release/serial${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
- uses: actions/checkout@v4
- name: Build check
run: cargo build --bin rayhunter-check --release
- uses: actions/upload-artifact@v4
with:
name: rayhunter-check-${{ matrix.platform.name }}
path: target/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
build_rootshell:
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
build_rayhunter:
strategy:
matrix:
device:
- name: tplink
- name: orbic
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-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 rayhunter-daemon (arm32)
run: cargo build --bin rayhunter-daemon --target armv7-unknown-linux-gnueabihf --release --no-default-features --features ${{ matrix.device.name }}
- uses: actions/upload-artifact@v4
with:
name: rayhunter-daemon-${{ matrix.device.name }}
path: target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon
if-no-files-found: error
build_release_zip:
needs:
- build_serial_and_check
- build_rootshell
- build_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-check-*/rayhunter-check rayhunter-daemon-*/rayhunter-daemon
- name: Setup release directory
run: mv 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,41 +0,0 @@
name: Check and Test
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
check_and_test:
strategy:
matrix:
device:
- name: tplink
- name: orbic
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check
run: cargo check --verbose --no-default-features --features=${{ matrix.device.name }}
- name: Run tests
run: cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
windows_serial_check_and_test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: cargo check
shell: bash
run: |
cd serial
cargo check --verbose
- name: cargo test
shell: bash
run: |
cd serial
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}

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

@@ -0,0 +1,358 @@
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: dtolnay/rust-toolchain@stable
- 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-latest
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-latest
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"
# Handle installer with proper extension for Windows
if [ "$platform" = "windows-x86_64" ]; then
mv installer-$platform/installer.exe "$dest"/installer.exe
else
mv installer-$platform/installer "$dest"/installer
fi
cp -r rayhunter-check-* rayhunter-daemon rootshell/rootshell dist/* installer/install.ps1 "$dest"/
zip -r "$dest.zip" "$dest"
sha256sum "$dest.zip" > "$dest.zip.sha256"
- 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

3257
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"

131
README.md
View File

@@ -1,130 +1,19 @@
# Rayhunter
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/main.yml/badge.svg)
![Rayhunter Logo - An Orca taking a bite out of a cellular signal bar](https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png)
# Rayhunter
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](https://efforg.github.io/rayhunter/supported-devices.md).
It's also designed to be as easy to install and use as possible, regardless of your level of technical skills, and to minimize false positives.
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/check-and-test.yml/badge.svg)
&rarr; Check out the [installation guide](https://efforg.github.io/rayhunter/installation.md) to get started.
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot.
&rarr; To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying).
**THIS CODE IS A PROOF OF CONCEPT AND SHOULD NOT BE RELIED UPON IN HIGH RISK SITUATIONS!**
&rarr; For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](https://efforg.github.io/rayhunter/support-feedback-community.md)!
## The Hardware
Rayhunter has been 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.
You can buy the orbic [using bezos bucks](https://www.amazon.com/Orbic-Verizon-Hotspot-Connect-Enabled/dp/B08N3CHC4Y),
or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
## Setup (Mac, Linux)
1. Download the latest `release.tar` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases)
2. Unzip the `release.tar`. Open the terminal and navigate to the folder
```bash
cd ~/Downloads/release
```
3. Turn on the Orbic device by holding the power button for 3 seconds. Plug it into your computer using a USB-C Cable.
4. Run the install script for your operating system:
```bash
./install.sh
```
The device will restart multiple times over the next few minutes.
You will know it is done when you see terminal output that says `checking for rayhunter server...success!`
5. Rayhunter should now be running! You can verify this by following the instructions below to [view the web UI](#usage-viewing-the-web-ui). You should also see a green line flash along the top of top the display on the device.
### Installation Notes
* Note: If you are installing from the cloned GitHub repository please see the development instructions below, running `install.sh` from the git tree will not work.
* The install script has only been tested for Linux on the latest version of Ubuntu. If it fails you will need to follow the install steps outlined in **Development** below.
* On macOS if you encounter an error that says "No Orbic device found," it may because you the "Allow accessories to connect" security setting set to "Ask for approval." You may need to temporarily change it to "Always" for the script to run. Make sure to change it back to a more secure setting when you're done.
## Setup (Windows)
We don't currently support automated installs on Windows.
## Updating
Great news: if you've successfully installed rayhunter, you already know how to update it! Our update process is identical to the setup process: simply download the latest release and follow the steps in the [setup section](#setup-silicon-mac-linux).
## Usage (viewing the web UI)
Once installed, Rayhunter will run automatically whenever your Orbic device is running. You'll see a green line on top of the device's display to indicate that it's running and recording. [The line will turn red](#red) once a potential IMSI catcher has been found, until the device is rebooted or a new recording is started through the web UI.
It also 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. **Connect over wifi:** Connect your phone/laptop to the Orbic's 2.4GHz wifi network and visit [http://192.168.1.1:8080](http://192.168.1.1:8080). (Click past your browser warning you about the connection not being secure, Rayhunter doesn't have HTTPS yet).
* You can find the wifi network password by going to the Orbic's menu > 2.4 GHz WIFI Info > Enter > find the 8-character password next to the lock 🔒 icon.
2. **Connect over USB:** Connect the Orbic device to your laptop via USB. Run `adb forward tcp:8080 tcp:8080`, then visit [http://localhost:8080](http://localhost:8080).
* For this you will need to install the Android Debug Bridge (ADB) on your computer, you can copy the version that was downloaded inside the `releases/platform-tools/` folder to somewhere else in your path or you can install it manually.
* You can find instructions for doing so on your platform [here](https://www.xda-developers.com/install-adb-windows-macos-linux/#how-to-set-up-adb-on-your-computer), (don't worry about instructions for installing it on a phone/device yet).
* On macOS, the easiest way to install ADB is with Homebrew: First [install Homebrew](https://brew.sh/), then run `brew install android-platform-tools`.
## Frequently Asked Questions
### Do I need an active SIM card to use Rayhunter?
**It Depends**. Operation of Rayhunter does require the insertion of a SIM card into the device, but whether that SIM card has to be currently active for our tests to work is still under investigation. If you want to use the device as a hotspot in addition to a research device an active plan would of course be necessary, however we have not done enough testing yet to know whether an active subscription is required for detection. If you want to test the device with an inactive SIM card, we would certainly be interested in seeing any data you collect, and especially any runs that trigger an alert!
<a name="red"></a>
### Help, Rayhunter's line is red! What should I do?
Unfortunately, the circumstances that might lead to a positive cell site simulator (CSS) signal are quite varied, so we don't have a universal recommendation for how to deal with the a positive signal. Depending on your circumstances and threat model, you may want to turn off your phone until you are out of the area (or put it on airplane mode) and tell your friends to do the same!
If you've received a Rayhunter warning and would like to help us with our research, please send your Rayhunter data captures (QMDL and PCAP logs) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version. If you're unfamiliar with Signal, feel free to check out our [Security Self Defense guide on it](https://ssd.eff.org/module/how-to-use-signal).
Please note that this file may contain sensitive information such as your IMSI and the unique IDs of cell towers you were near which could be used to ascertain your location at the time.
### Does Rayhunter work outside of the US?
**Probably**. Some Rayhunter users have reported successfully using it in other countries with unlocked devices and SIM cards from local telcos. We can't guarantee whether or not it will work for you though.
### Should I get a locked or unlocked orbic device? What is the difference?
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear how locked the locked devices are nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices.
### Does Rayhunter work on any other devices besides the Orbic RC400L?
**Maybe**. We have not tested Rayhunter on any other hardware but we would love to expand the supported platforms. We will consider giving official support to any hardware platform that can be bought for around $20-30USD. The Rayhunter daemon should theoretically work on any Linux/Android device that has a qualcomm chip with a `/dev/diag` interface and root access, though our installer script has only been tested with an Orbic. If you get it working on another device, please let us know!
### How do I delete capture files from the Rayhunter device?
You can get a shell on the device by inputting `adb shell` to a terminal with the device connected, you can check if it is detected with `adb devices`.
The capture files are located at */data/rayhunter/qmdl* but you will need root access to modify or delete them. From the adb shell run `/bin/rootshell` and you can now use commands like 'rm' as root to modify and delete entries in the */data/rayhunter/qmdl* directory. **Be careful not to delete important files in other directories as you may seriously damage the device**
## Development
Follow these instructions if you need to build Rayhunter from source rather than using our [compiled builds](https://github.com/EFForg/rayhunter/releases).
* Install ADB on your computer using the instructions above, and make sure it's in your terminal's PATH
* You can verify if ADB is in your PATH by running `which adb` in a terminal. If it prints the filepath to where ADB is installed, you're set! Otherwise, try following one of these guides:
* [linux](https://askubuntu.com/questions/652936/adding-android-sdk-platform-tools-to-path-downloaded-from-umake)
* [macOS](https://www.repeato.app/setting-up-adb-on-macos-a-step-by-step-guide/)
* [Windows](https://medium.com/@yadav-ajay/a-step-by-step-guide-to-setting-up-adb-path-on-windows-0b833faebf18)
### If you're on x86 linux
Install Rust the usual way and then install cross compiling dependences:
```bash
sudo apt install curl build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf
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`
## Support and Discussion
If you're having issues installing or using Rayhunter, please open an issue in this repo. Join us in the `#rayhunter` channel of [EFF's Mattermost](https://opensource.eff.org/signup_user_complete/?id=r1b6cnta9bysxk6im3kuabiu1y&md=link&sbr=su) instance to chat!
&rarr; To learn more about the project in general check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
*Good Hunting!*
*Good Hunting!*

View File

@@ -1,43 +0,0 @@
[package]
name = "rayhunter-daemon"
version = "0.2.8"
edition = "2021"
[features]
# These feature flags are mutually exclusive, and exactly one must be enabled.
orbic = ["rayhunter/orbic"]
tplink = ["rayhunter/tplink"]
default = ["orbic"]
[[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.44.2", features = ["full"] }
axum = "0.8"
futures-core = "0.3.30"
thiserror = "1.0.52"
libc = "0.2.150"
log = "0.4.20"
env_logger = "0.10.1"
tokio-util = { version = "0.7.10", features = ["rt", "io"] }
futures-macro = "0.3.30"
include_dir = "0.7.3"
mime_guess = "2.0.4"
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 = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
tempfile = "3.10.1"
simple_logger = "5.0.0"

View File

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

View File

@@ -1,308 +0,0 @@
use std::pin::pin;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt};
use log::{debug, error, info};
use rayhunter::diag::DataType;
use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter;
use tokio::fs::File;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::RwLock;
use tokio_util::io::ReaderStream;
use tokio_util::task::TaskTracker;
use crate::analysis::AnalysisWriter;
use crate::display;
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::server::ServerState;
pub enum DiagDeviceCtrlMessage {
StopRecording,
StartRecording((QmdlWriter<File>, File)),
Exit,
}
pub fn run_diag_read_thread(
task_tracker: &TaskTracker,
mut dev: DiagDevice,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
ui_update_sender: Sender<display::DisplayState>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
enable_dummy_analyzer: bool,
) {
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, enable_dummy_analyzer).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, enable_dummy_analyzer).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_output = analysis_writer.analyze(container).await
.expect("failed to analyze container");
let (analysis_file_len, heuristic_warning) = analysis_output;
if heuristic_warning {
info!("a heuristic triggered on this run!");
ui_update_sender.send(display::DisplayState::WarningDetected).await
.expect("couldn't send ui update message: {}");
}
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).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.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug 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),
)
})?;
let display_state = display::DisplayState::Recording;
state
.ui_update_sender
.send(display_state)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
pub async fn stop_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
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),
)
})?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
pub async fn delete_recording(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let mut qmdl_store = state.qmdl_store_lock.write().await;
match qmdl_store.delete_entry(&qmdl_name).await {
Err(RecordingStoreError::NoSuchEntryError) => {
return Err((
StatusCode::BAD_REQUEST,
format!("no recording with name {qmdl_name}"),
))
}
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recording: {e}"),
))
}
Ok(_) => {}
}
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {}", e),
)
})?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
pub async fn delete_all_recordings(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let mut qmdl_store = state.qmdl_store_lock.write().await;
qmdl_store.delete_all_entries().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete all recordings: {}", 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),
)
})?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {}", e),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
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

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

View File

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

View File

@@ -1,51 +0,0 @@
use std::borrow::Cow;
use rayhunter::telcom_parser::lte_rrc::{PCCH_MessageType, PCCH_MessageType_c1, PagingUE_Identity};
use rayhunter::analysis::analyzer::{Analyzer, Event, EventType, Severity};
use rayhunter::analysis::information_element::{InformationElement, LteInformationElement};
pub struct TestAnalyzer {
pub count: i32,
}
impl Analyzer for TestAnalyzer {
fn get_name(&self) -> Cow<str> {
Cow::from("Example Analyzer")
}
fn get_description(&self) -> Cow<str> {
Cow::from("Always returns true, if you are seeing this you are either a developer or you are about to have problems.")
}
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
self.count += 1;
if self.count % 100 == 0 {
return Some(Event {
event_type: EventType::Informational,
message: "multiple of 100 events processed".to_string(),
});
}
let pcch_msg = match ie {
InformationElement::LTE(lte_ie) => match &**lte_ie {
LteInformationElement::PCCH(pcch_msg) => pcch_msg,
_ => return None,
},
_ => return None,
};
let PCCH_MessageType::C1(PCCH_MessageType_c1::Paging(paging)) = &pcch_msg.message else {
return None;
};
for record in &paging.paging_record_list.as_ref()?.0 {
if let PagingUE_Identity::S_TMSI(_) = record.ue_identity {
return Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::Low,
},
message: "TMSI was provided to cell".to_string(),
});
}
}
None
}
}

View File

@@ -1,110 +0,0 @@
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 include_dir::{include_dir, Dir};
use std::sync::Arc;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use tokio::sync::mpsc::Sender;
use tokio::sync::RwLock;
use tokio_util::io::ReaderStream;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::qmdl_store::RecordingStore;
use crate::{display, DiagDeviceCtrlMessage};
pub struct ServerState {
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
pub ui_update_sender: Sender<display::DisplayState>,
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub debug_mode: bool,
}
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(|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"),
(CONTENT_LENGTH, &entry.qmdl_size_bytes.to_string()),
];
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(
State(state): State<Arc<ServerState>>,
Path(path): Path<String>,
) -> impl IntoResponse {
let path = path.trim_start_matches('/');
let mime_type = mime_guess::from_path(path).first_or_text_plain();
// if we're in debug mode, return the files from the build directory so we
// don't have to rebuild every time the JS/HTML change
if state.debug_mode {
let mut build_path = std::path::PathBuf::new();
build_path.push("bin");
build_path.push("static");
for part in path.split("/") {
build_path.push(part);
}
return match File::open(build_path).await {
Ok(mut file) => {
let mut body = String::new();
file.read_to_string(&mut body)
.await
.expect("failed to read file");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_str(mime_type.as_ref()).unwrap(),
)
.body(Body::from(body))
.unwrap()
}
Err(_) => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap(),
};
}
match STATIC_DIR.get_file(path) {
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,45 +0,0 @@
td,
th {
border: 1px solid rgb(190, 190, 190);
padding: 10px;
}
td {
text-align: center;
}
tr:nth-child(even) {
background-color: #eee;
}
th[scope='col'] {
background-color: #696969;
color: #fff;
}
th[scope='row'] {
background-color: #d7d9f2;
}
tr.current {
background-color: #53fe7b;
font-weight: bold;
}
tr.warning {
background-color: #fe537b;
font-weight: bold;
}
caption {
padding: 10px;
caption-side: bottom;
}
table {
border-collapse: collapse;
border: 2px solid rgb(200, 200, 200);
letter-spacing: 1px;
font-family: sans-serif;
font-size: 0.8rem;
}

View File

@@ -1,46 +0,0 @@
<!DOCTYPE html>
<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>
<button onclick="deleteAllRecodings()">Delete All Recordings</button>
</div>
<table id="qmdl-manifest-table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Date Started</th>
<th scope="col">Date of Last Message</th>
<th scope="col">Size (bytes)</th>
<th scope="col">PCAP</th>
<th scope="col">QMDL</th>
<th scope="col">Analysis Result</th>
<th scope="col">Actions</th>
</tr>
</thead>
</table>
<div>
<h3>Live System stats</h3>
<pre id="system-stats">Loading...</pre>
</div>
<div>
<h3>Analysis Report of Current Capture</h3>
<pre id="analysis-report">Loading...</pre>
</div>
</body>
</html>

View File

@@ -1,235 +0,0 @@
const STATUS_RUNNING = 'running';
const STATUS_QUEUED = 'queued';
const STATUS_NEEDS_UPDATE = 'needs-update';
const STATUS_COMPLETE = 'complete';
async function populateDivs() {
const systemStats = await getSystemStats();
const systemStatsDiv = document.getElementById('system-stats');
systemStatsDiv.innerHTML = JSON.stringify(systemStats, null, 2);
const analysisReportDiv = document.getElementById('analysis-report');
try {
const analysisReport = await getAnalysisReport('live');
analysisReportDiv.innerHTML = JSON.stringify(analysisReport, null, 2);
} catch (e) {
analysisReportDiv.innerHTML = e.toString();
}
const qmdlManifest = await getQmdlManifest();
await updateAnalysisStatus(qmdlManifest);
await updateAnalysisResults(qmdlManifest);
updateQmdlManifestTable(qmdlManifest);
}
function setStatus(qmdlManifest, name, status) {
// ignore qmdlManifest.current_entry, it's always running
for (const entry of qmdlManifest.entries) {
if (entry.name === name) {
entry['status'] = status;
return;
}
}
}
async function updateAnalysisStatus(qmdlManifest) {
const status = JSON.parse(await req('GET', '/api/analysis'));
if (status.running) {
setStatus(qmdlManifest, status.running, STATUS_RUNNING);
}
for (const queued in status.queued) {
setStatus(qmdlManifest, queued, STATUS_QUEUED);
}
}
function parseNewlineDelimitedJSON(inputStr) {
const lines = inputStr.split('\n');
const result = [];
let currentLine = '';
while (lines.length > 0) {
currentLine += lines.shift();
try {
const entry = JSON.parse(currentLine);
result.push(entry);
currentLine = '';
// if this chunk wasn't valid JSON, there was an escaped newline in the
// JSON line, so simply continue to the next one
} catch (e) {}
}
return result;
}
async function updateEntryAnalysisResult(entry) {
entry.analysis = {
warnings: [],
};
const report = parseNewlineDelimitedJSON(await req('GET', `/api/analysis-report/${entry.name}`));
for (const row of report) {
if (row["analysis"]) {
const timestamp = new Date(row["timestamp"]);
const analysis = row["analysis"];
for (const warning of analysis) {
entry.analysis.warnings.push({
timestamp,
warning,
})
}
}
}
if (entry.analysis.warnings.length === 0) {
entry.analysis_result = `0 warnings!`;
} else {
entry.analysis_result = `!!! ${entry.analysis.warnings.length} warnings !!!`;
for (const warning of entry.analysis.warnings) {
for (const event of warning.warning.events) {
if (event === null) continue;
msg = `${warning.timestamp}: ${event.message}`
entry.analysis_result += `<br>${msg}`
}
}
}
}
async function updateAnalysisResults(qmdlManifest) {
if (qmdlManifest.current_entry) {
await updateEntryAnalysisResult(qmdlManifest.current_entry);
}
for (const entry of qmdlManifest.entries) {
if (entry.status === STATUS_NEEDS_UPDATE) {
await updateEntryAnalysisResult(entry);
entry.status = STATUS_COMPLETE;
}
}
}
function updateQmdlManifestTable(manifest) {
const table = document.getElementById('qmdl-manifest-table');
const numRows = table.rows.length;
for (let i=1; i<numRows; i++) {
table.deleteRow(1);
}
if (manifest.current_entry) {
const row = createEntryRow(manifest.current_entry, true);
row.classList.add('current');
table.appendChild(row)
}
for (let entry of manifest.entries) {
table.appendChild(createEntryRow(entry), false);
}
}
function createLink(uri, text) {
const link = document.createElement('a');
link.href = uri;
link.innerText = text;
return link;
}
function createButton(uri, text) {
const link = document.createElement('button');
link.innerText = text;
link.onclick = async () => {
await req('POST', uri);
populateDivs();
};
return link;
}
function createEntryRow(entry, isCurrent) {
const row = document.createElement('tr');
const name = document.createElement('th');
name.scope = 'row';
name.innerText = entry.name;
row.appendChild(name);
for (const key of ['start_time', 'last_message_time', 'qmdl_size_bytes']) {
const td = document.createElement('td');
td.innerText = entry[key];
row.appendChild(td);
}
const pcapTd = document.createElement('td');
pcapTd.appendChild(createLink(`/api/pcap/${entry.name}`, 'pcap'));
row.appendChild(pcapTd);
const qmdlTd = document.createElement('td');
qmdlTd.appendChild(createLink(`/api/qmdl/${entry.name}.qmdl`, 'qmdl'));
row.appendChild(qmdlTd);
const analysisResult = document.createElement('td');
analysisResult.innerHTML = entry.analysis_result;
if (entry.analysis.warnings.length > 0) {
row.classList.add("warning");
}
row.appendChild(analysisResult);
const actionsButtons = document.createElement('td');
actionsButtons.appendChild(createButton(`/api/delete-recording/${entry.name}`, 'Delete'));
row.appendChild(actionsButtons);
return row;
}
async function getAnalysisReport(name) {
const rows = await req('GET', `/api/analysis-report/${name}`);
return rows.split('\n')
.filter(row => row.length > 0)
.map(row => JSON.parse(row));
}
async function getSystemStats() {
return JSON.parse(await req('GET', '/api/system-stats'));
}
async function getQmdlManifest() {
const manifest = JSON.parse(await req('GET', '/api/qmdl-manifest'));
if (manifest.current_entry) {
parseQmdlEntry(manifest.current_entry);
}
for (entry of manifest.entries) {
parseQmdlEntry(entry);
}
// sort them in reverse chronological order
manifest.entries.reverse();
return manifest;
}
function parseQmdlEntry(entry) {
entry.status = STATUS_NEEDS_UPDATE;
entry.analysis_result = 'Waiting...';
entry.start_time = new Date(entry.start_time);
if (entry.last_message_time === null) {
entry.last_message_time = "N/A";
} else {
entry.last_message_time = new Date(entry.last_message_time);
}
}
async function startRecording() {
await req('POST', '/api/start-recording');
populateDivs();
}
async function stopRecording() {
await req('POST', '/api/stop-recording');
populateDivs();
}
async function deleteAllRecodings() {
if (window.confirm("Are you sure you want to permanently delete all of your recordings?")) {
await req('POST', '/api/delete-all-recordings');
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.6.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::Low | EventType::Medium | EventType::High => {
warn!(
"{}: WARNING (Severity: {:?}) - {} {}",
self.file_path, event.event_type, 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;
}
}
}

32
daemon/Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "rayhunter-daemon"
version = "0.6.1"
edition = "2024"
rust-version = "1.88.0"
[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, features = ["io-util"] }
futures = { version = "0.3.30", default-features = false }
serde_json = "1.0.114"
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
tempfile = "3.10.1"
async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98"
reqwest = { version = "0.12.20", default-features = false, features = [
"rustls-tls-webpki-roots-no-provider",
] }
rustls-rustcrypto = "0.0.2-alpha"
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

View File

@@ -1,5 +1,5 @@
use std::sync::Arc;
use std::{future, pin};
use std::{cmp, future, pin};
use axum::Json;
use axum::{
@@ -7,8 +7,8 @@ use axum::{
http::StatusCode,
};
use futures::TryStreamExt;
use log::{debug, error, info};
use rayhunter::analysis::analyzer::Harness;
use log::{error, info};
use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness};
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::qmdl::QmdlReader;
use serde::Serialize;
@@ -18,14 +18,12 @@ use tokio::sync::mpsc::Receiver;
use tokio::sync::{RwLock, RwLockWriteGuard};
use tokio_util::task::TaskTracker;
use crate::dummy_analyzer::TestAnalyzer;
use crate::qmdl_store::RecordingStore;
use crate::server::ServerState;
pub struct AnalysisWriter {
writer: BufWriter<File>,
harness: Harness,
bytes_written: usize,
}
// We write our analysis results to a file immediately to minimize the amount of
@@ -35,15 +33,11 @@ pub struct AnalysisWriter {
// lets us simply append new rows to the end without parsing the entire JSON
// object beforehand.
impl AnalysisWriter {
pub async fn new(file: File, enable_dummy_analyzer: bool) -> Result<Self, std::io::Error> {
let mut harness = Harness::new_with_all_analyzers();
if enable_dummy_analyzer {
harness.add_analyzer(Box::new(TestAnalyzer { count: 0 }));
}
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),
bytes_written: 0,
harness,
};
let metadata = result.harness.get_metadata();
@@ -52,22 +46,25 @@ impl AnalysisWriter {
}
// Runs the analysis harness on the given container, serializing the results
// to the analysis file and returning the file's new length.
// to the analysis file, returning the whether any warnings were detected
pub async fn analyze(
&mut self,
container: MessagesContainer,
) -> Result<(usize, bool), std::io::Error> {
let row = self.harness.analyze_qmdl_messages(container);
if !row.is_empty() {
self.write(&row).await?;
) -> Result<EventType, std::io::Error> {
let mut max_type = EventType::Informational;
for row in self.harness.analyze_qmdl_messages(container) {
if !row.is_empty() {
self.write(&row).await?;
}
max_type = cmp::max(max_type, row.get_max_event_type());
}
Ok((self.bytes_written, row.contains_warnings()))
Ok(max_type)
}
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(())
@@ -80,14 +77,32 @@ impl AnalysisWriter {
}
}
#[derive(Debug, Serialize, Clone, Default)]
#[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,
}
@@ -103,70 +118,67 @@ async fn dequeue_to_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) -
name
}
async fn clear_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) {
async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) {
let mut analysis_status = analysis_status_lock.write().await;
analysis_status.running = None;
let finished = analysis_status.running.take().unwrap();
analysis_status.finished.push(finished);
}
async fn perform_analysis(
name: &str,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
enable_dummy_analyzer: bool,
analyzer_config: &AnalyzerConfig,
) -> Result<(), String> {
info!("Opening QMDL and analysis file for {}...", name);
let (analysis_file, qmdl_file, entry_index) = {
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))?;
.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))?;
.map_err(|e| format!("{e:?}"))?;
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|e| format!("{:?}", e))?;
.map_err(|e| format!("{e:?}"))?;
(analysis_file, qmdl_file, entry_index)
(analysis_file, qmdl_file)
};
let mut analysis_writer = AnalysisWriter::new(analysis_file, enable_dummy_analyzer)
let mut analysis_writer = AnalysisWriter::new(analysis_file, analyzer_config)
.await
.map_err(|e| format!("{:?}", e))?;
.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)));
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);
info!("Starting analysis for {name}...");
while let Some(container) = qmdl_stream
.try_next()
.await
.expect("failed getting QMDL container")
{
let (size_bytes, _) = analysis_writer
let _ = analysis_writer
.analyze(container)
.await
.map_err(|e| format!("{:?}", e))?;
debug!("{} analysis: {} bytes written", name, size_bytes);
let mut qmdl_store = qmdl_store_lock.write().await;
qmdl_store
.update_entry_analysis_size(entry_index, size_bytes)
.await
.map_err(|e| format!("{:?}", e))?;
.map_err(|e| format!("{e:?}"))?;
}
analysis_writer
.close()
.await
.map_err(|e| format!("{:?}", e))?;
info!("Analysis for {} complete!", name);
.map_err(|e| format!("{e:?}"))?;
info!("Analysis for {name} complete!");
Ok(())
}
@@ -176,7 +188,7 @@ pub fn run_analysis_thread(
mut analysis_rx: Receiver<AnalysisCtrlMessage>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
enable_dummy_analyzer: bool,
analyzer_config: AnalyzerConfig,
) {
task_tracker.spawn(async move {
loop {
@@ -186,14 +198,17 @@ pub fn run_analysis_thread(
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(), enable_dummy_analyzer)
.await
perform_analysis(&name, qmdl_store_lock.clone(), &analyzer_config).await
{
error!("failed to analyze {}: {}", name, err);
error!("failed to analyze {name}: {err}");
}
clear_running(analysis_status_lock.clone()).await;
finish_running_analysis(analysis_status_lock.clone()).await;
}
}
Some(AnalysisCtrlMessage::RecordingFinished(name)) => {
let mut status = analysis_status_lock.write().await;
status.finished.push(name);
}
Some(AnalysisCtrlMessage::Exit) | None => return,
}
}
@@ -246,7 +261,7 @@ pub async fn start_analysis(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to queue new analysis files: {:?}", e),
format!("failed to queue new analysis files: {e:?}"),
)
})?;
}

View File

@@ -1,16 +1,23 @@
use log::warn;
use serde::{Deserialize, Serialize};
use rayhunter::Device;
use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[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 enable_dummy_analyzer: bool,
pub colorblind_mode: bool,
pub key_input_mode: u8,
pub ntfy_url: Option<String>,
pub analyzers: AnalyzerConfig,
}
impl Default for Config {
@@ -19,20 +26,24 @@ impl Default for Config {
qmdl_store_path: "/data/rayhunter/qmdl".to_string(),
port: 8080,
debug_mode: false,
device: Device::Orbic,
ui_level: 1,
enable_dummy_analyzer: false,
colorblind_mode: false,
key_input_mode: 0,
analyzers: AnalyzerConfig::default(),
ntfy_url: None,
}
}
}
pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
where
P: AsRef<std::path::Path>,
{
if let Ok(config_file) = std::fs::read_to_string(&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())
}
}

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

@@ -0,0 +1,451 @@
use std::ops::DerefMut;
use std::pin::pin;
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt, future};
use log::{debug, error, info, warn};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::{RwLock, oneshot};
use tokio_stream::wrappers::LinesStream;
use tokio_util::task::TaskTracker;
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter;
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
use crate::display;
use crate::notifications::Notification;
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,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
state: DiagState,
max_type_seen: EventType,
}
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,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
) -> Self {
Self {
ui_update_sender,
analysis_sender,
analyzer_config,
notification_channel,
state: DiagState::Stopped,
max_type_seen: EventType::Informational,
}
}
/// 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()
&& 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 max_type = analysis_writer
.analyze(container)
.await
.expect("failed to analyze container");
if max_type > EventType::Informational {
info!("a heuristic triggered on this run!");
self.notification_channel
.send(Notification::new(
"heuristic-warning".to_string(),
format!("Rayhunter has detected a {:?} severity event", max_type),
Some(Duration::from_secs(60 * 5)),
))
.await
.expect("Failed to send to notification channel");
}
if max_type > self.max_type_seen {
self.max_type_seen = max_type;
if self.max_type_seen > EventType::Informational {
self.ui_update_sender
.send(display::DisplayState::WarningDetected {
event_type: self.max_type_seen,
})
.await
.expect("couldn't send ui update message: {}");
}
}
} else {
debug!("no qmdl_writer set, continuing...");
}
}
}
#[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,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
) {
task_tracker.spawn(async move {
let mut diag_stream = pin!(dev.as_stream().into_stream());
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel);
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:?}")))?;
// Read and normalize the NDJSON file
let reader = BufReader::new(analysis_file);
let lines_stream = LinesStream::new(reader.lines());
let mut normalizer = AnalysisLineNormalizer::new();
let normalized_stream = lines_stream
.try_filter(|line| future::ready(!line.is_empty()))
.map_ok(move |line| normalizer.normalize_line(line));
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
let body = Body::from_stream(normalized_stream);
Ok((headers, body).into_response())
}

View File

@@ -0,0 +1,242 @@
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 rayhunter::analysis::analyzer::EventType;
use log::{error, info};
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError;
use tokio_util::task::TaskTracker;
use include_dir::{Dir, include_dir};
const REFRESH_RATE: u64 = 1000; //how often in milliseconds to refresh the display
#[derive(Copy, Clone)]
pub struct Dimensions {
pub height: u32,
pub width: u32,
}
#[derive(Copy, Clone)]
pub enum LinePattern {
Solid,
Dashed, // _ _ _ _
Dotted, // . . . .
}
#[allow(dead_code)]
#[derive(Copy, Clone)]
pub enum Color {
Red,
Green,
Blue,
White,
Black,
Cyan,
Yellow,
Pink,
Orange,
}
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),
Color::Orange => (0xff, 0xa5, 0),
}
}
}
fn display_style_from_state(state: DisplayState, colorblind_mode: bool) -> (Color, LinePattern) {
match state {
DisplayState::Paused => (Color::White, LinePattern::Solid),
DisplayState::Recording => {
if colorblind_mode {
(Color::Blue, LinePattern::Solid)
} else {
(Color::Green, LinePattern::Solid)
}
}
DisplayState::WarningDetected { event_type } => match event_type {
EventType::Informational => {
if colorblind_mode {
(Color::Blue, LinePattern::Solid)
} else {
(Color::Green, LinePattern::Solid)
}
}
EventType::Low => (Color::Yellow, LinePattern::Dotted),
EventType::Medium => (Color::Orange, LinePattern::Dashed),
EventType::High => (Color::Red, LinePattern::Solid),
},
}
}
#[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) {
self.draw_patterned_line(color, height, LinePattern::Solid)
.await
}
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
let width = self.dimensions().width;
let mut buffer = Vec::new();
for _row in 0..height {
for col in 0..width {
let should_draw = match pattern {
LinePattern::Solid => true,
LinePattern::Dashed => (col / 4) % 2 == 0, // 4 pixels on, 4 pixels off
LinePattern::Dotted => col % 4 == 0, // 1 pixel on, 3 pixels off
};
if should_draw {
buffer.push(color.rgb());
} else {
buffer.push((0, 0, 0)); // Black background
}
}
}
self.write_buffer(buffer).await
}
}
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_style = display_style_from_state(DisplayState::Recording, colorblind_mode);
task_tracker.spawn(async move {
// this feels wrong, is there a more rusty way to do this?
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_style = display_style_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
_ => {}
};
let (color, pattern) = display_style;
fb.draw_patterned_line(color, 2, pattern).await;
tokio::time::sleep(Duration::from_millis(REFRESH_RATE)).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.");
}

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

@@ -0,0 +1,26 @@
use rayhunter::analysis::analyzer::EventType;
use serde::{Deserialize, Serialize};
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 uz801;
pub mod wingtech;
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum DisplayState {
/// We're recording but no warning has been found yet.
Recording,
/// We're not recording.
Paused,
/// A non-informational event has been detected.
///
/// Note that EventType::Informational is never sent through this. If it is, it's the same as
/// Recording
WarningDetected { event_type: EventType },
}

View File

@@ -1,6 +1,7 @@
use crate::config;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
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;
@@ -11,6 +12,7 @@ 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?
@@ -20,16 +22,16 @@ impl GenericFramebuffer for Framebuffer {
}
}
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
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;
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (b as u16) >> 3;
raw_buffer.extend(rgb565.to_le_bytes());
}
std::fs::write(FB_PATH, &raw_buffer).unwrap();
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
}
}

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

@@ -4,7 +4,7 @@ use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use crate::config;
use crate::display::{tplink_framebuffer, tplink_onebit, DisplayState};
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
use std::fs;
@@ -19,6 +19,8 @@ pub fn update_ui(
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)

View File

@@ -1,10 +1,11 @@
use std::fs::File;
use std::io::Write;
use async_trait::async_trait;
use std::os::fd::AsRawFd;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use crate::config;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
@@ -24,6 +25,7 @@ struct fb_fillrect {
rop: u32,
}
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
@@ -33,12 +35,12 @@ impl GenericFramebuffer for Framebuffer {
}
}
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
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 = File::options().write(true).open(FB_PATH).unwrap();
let mut f = OpenOptions::new().write(true).open(FB_PATH).await.unwrap();
let mut arg = fb_fillrect {
dx: 0,
dy: 0,
@@ -50,15 +52,16 @@ impl GenericFramebuffer for Framebuffer {
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;
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (b as u16) >> 3;
// note: big-endian!
raw_buffer.extend(rgb565.to_be_bytes());
}
f.write_all(&raw_buffer).unwrap();
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(),
@@ -68,7 +71,7 @@ impl GenericFramebuffer for Framebuffer {
);
if res < 0 {
panic!("failed to send FBIORECT_DISPLAY ioctl, {}", res);
panic!("failed to send FBIORECT_DISPLAY ioctl, {res}");
}
}
}

View File

@@ -10,8 +10,6 @@ use tokio::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError;
use tokio_util::task::TaskTracker;
use std::fs;
use std::thread::sleep;
use std::time::Duration;
pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer";
@@ -122,7 +120,7 @@ pub fn update_ui(
info!("Invisible mode, not spawning UI.");
}
task_tracker.spawn_blocking(move || {
task_tracker.spawn(async move {
let mut pixels = STATUS_SMILING;
loop {
@@ -138,7 +136,7 @@ pub fn update_ui(
match ui_update_rx.try_recv() {
Ok(DisplayState::Paused) => pixels = STATUS_PAUSED,
Ok(DisplayState::Recording) => pixels = STATUS_SMILING,
Ok(DisplayState::WarningDetected) => pixels = STATUS_WARNING,
Ok(DisplayState::WarningDetected { .. }) => pixels = STATUS_WARNING,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(e) => {
error!("error receiving framebuffer update message: {e}");
@@ -147,13 +145,13 @@ pub fn update_ui(
// we write the status every second because it may have been overwritten through menu
// navigation.
if display_level != 0 {
if let Err(e) = fs::write(OLED_PATH, &pixels) {
error!("failed to write to display: {e}");
}
if display_level != 0
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
{
error!("failed to write to display: {e}");
}
sleep(Duration::from_millis(1000));
tokio::time::sleep(Duration::from_millis(1000)).await;
}
});
}

View File

@@ -0,0 +1,89 @@
/// Display module for Uz801, light LEDs on the front of the device.
/// DisplayState::Recording => Green LED is solid.
/// DisplayState::Paused => Signal LED is solid blue (wifi LED).
/// DisplayState::WarningDetected => Signal LED is solid red.
use log::{error, info};
use tokio::sync::mpsc;
use tokio::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/{}/brightness", $l) }};
}
async fn led_on(path: String) {
tokio::fs::write(&path, "1").await.ok();
}
async fn led_off(path: String) {
tokio::fs::write(&path, "0").await.ok();
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
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;
let mut last_update = std::time::Instant::now();
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}"),
};
// Update LEDs if state changed or if 5 seconds have passed since last update
let now = std::time::Instant::now();
let should_update = !invisible
&& (state != last_state
|| now.duration_since(last_update) >= Duration::from_secs(5));
if should_update {
match state {
DisplayState::Paused => {
led_off(led!("red")).await;
led_off(led!("green")).await;
led_on(led!("wifi")).await;
}
DisplayState::Recording => {
led_off(led!("red")).await;
led_off(led!("wifi")).await;
led_on(led!("green")).await;
}
DisplayState::WarningDetected { .. } => {
led_off(led!("green")).await;
led_off(led!("wifi")).await;
led_on(led!("red")).await;
}
}
last_state = state;
last_update = now;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
}

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,
)
}

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
&& 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));
}
}

View File

@@ -2,40 +2,47 @@ mod analysis;
mod config;
mod diag;
mod display;
mod dummy_analyzer;
mod error;
mod key_input;
mod notifications;
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::notifications::{NotificationService, run_notification_worker};
use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore;
use crate::server::{get_qmdl, serve_static, ServerState};
use crate::stats::get_system_stats;
use crate::server::{
ServerState, debug_set_display_state, get_config, get_qmdl, get_zip, serve_static, set_config,
};
use crate::stats::{get_qmdl_manifest, get_system_stats};
use analysis::{
get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus,
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
};
use axum::Router;
use axum::response::Redirect;
use axum::routing::{get, post};
use axum::Router;
use diag::{
delete_all_recordings, delete_recording, get_analysis_report, start_recording, stop_recording,
DiagDeviceCtrlMessage,
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 stats::get_qmdl_manifest;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::select;
use tokio::sync::mpsc::{self, Sender};
use tokio::sync::{oneshot, RwLock};
use tokio::sync::{RwLock, oneshot};
use tokio::task::JoinHandle;
use tokio_util::task::TaskTracker;
@@ -45,6 +52,7 @@ 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))
@@ -54,6 +62,9 @@ fn get_router() -> AppRouter {
.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("/api/debug/display-state", post(debug_set_display_state))
.route("/", get(|| async { Redirect::permanent("/index.html") }))
.route("/{*path}", get(serve_static))
}
@@ -63,14 +74,14 @@ fn get_router() -> AppRouter {
// (i.e. user hit ctrl+c)
async fn run_server(
task_tracker: &TaskTracker,
config: &config::Config,
state: Arc<ServerState>,
server_shutdown_rx: oneshot::Receiver<()>,
) -> JoinHandle<()> {
info!("spinning up server");
let app = get_router().with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
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)
@@ -87,7 +98,7 @@ async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
// 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 by making a new (empty) manifest in the same directory.
// 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 {
@@ -102,9 +113,9 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
match RecordingStore::load(&config.qmdl_store_path).await {
Ok(store) => Ok(store),
Err(RecordingStoreError::ParseManifestError(err)) => {
error!("failed to parse QMDL manifest: {}", err);
info!("creating new empty manifest...");
Ok(RecordingStore::create(&config.qmdl_store_path).await?)
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()),
}
@@ -116,71 +127,109 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
// Start a thread that'll track when user hits ctrl+c. When that happens,
// trigger various cleanup tasks, including sending signals to other threads to
// shutdown
fn run_ctrl_c_thread(
#[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 {
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!");
select! {
res = tokio::signal::ctrl_c() => {
if let Err(err) = res {
error!("Unable to listen for shutdown signal: {err}");
}
server_shutdown_tx
.send(())
.expect("couldn't send server shutdown signal");
info!("sending UI shutdown");
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
ui_shutdown_tx
.send(())
.expect("couldn't send ui shutdown signal");
should_restart_flag.store(false, Ordering::Relaxed);
}
res = daemon_restart_rx => {
if let Err(err) = res {
error!("Unable to listen for shutdown signal: {err}");
}
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");
}
Err(err) => {
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]
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), RayhunterError> {
env_logger::init();
let args = parse_args();
let config = parse_config(&args.config_path)?;
rustls_rustcrypto::provider()
.install_default()
.expect("Couldn't install rustcrypto provider");
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 qmdl_store_lock = Arc::new(RwLock::new(init_qmdl_store(&config).await?));
let (tx, rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
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;
let notification_service = NotificationService::new(config.ntfy_url.clone());
if !config.debug_mode {
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
let mut dev = DiagDevice::new()
info!("Using configuration for device: {0:?}", config.device);
let mut dev = DiagDevice::new(&config.device)
.await
.map_err(RayhunterError::DiagInitError)?;
dev.config_logs()
@@ -191,47 +240,78 @@ async fn main() -> Result<(), RayhunterError> {
run_diag_read_thread(
&task_tracker,
dev,
rx,
diag_rx,
diag_tx.clone(),
ui_update_tx.clone(),
qmdl_store_lock.clone(),
config.enable_dummy_analyzer,
analysis_tx.clone(),
config.analyzers.clone(),
notification_service.new_handler(),
);
info!("Starting UI");
display::update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
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,
Device::Uz801 => display::uz801::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::<()>();
info!("create shutdown thread");
let analysis_status_lock = Arc::new(RwLock::new(AnalysisStatus::default()));
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
run_analysis_thread(
&task_tracker,
analysis_rx,
qmdl_store_lock.clone(),
analysis_status_lock.clone(),
config.enable_dummy_analyzer,
config.analyzers.clone(),
);
run_ctrl_c_thread(
let should_restart_flag = Arc::new(AtomicBool::new(false));
run_shutdown_thread(
&task_tracker,
tx.clone(),
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(),
);
run_notification_worker(&task_tracker, notification_service);
let state = Arc::new(ServerState {
config_path: args.config_path.clone(),
config,
qmdl_store_lock: qmdl_store_lock.clone(),
diag_device_ctrl_sender: tx,
ui_update_sender: ui_update_tx,
debug_mode: config.debug_mode,
diag_device_ctrl_sender: diag_tx,
analysis_status_lock,
analysis_sender: analysis_tx,
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
ui_update_sender: Some(ui_update_tx),
});
run_server(&task_tracker, &config, state, server_shutdown_rx).await;
run_server(&task_tracker, state, server_shutdown_rx).await;
task_tracker.close();
task_tracker.wait().await;
info!("see you space cowboy...");
Ok(())
Ok(should_restart_flag.load(Ordering::Relaxed))
}
#[cfg(test)]

148
daemon/src/notifications.rs Normal file
View File

@@ -0,0 +1,148 @@
use std::{
cmp::min,
collections::HashMap,
time::{Duration, Instant},
};
use log::error;
use tokio::sync::mpsc::{self, error::TryRecvError};
use tokio_util::task::TaskTracker;
pub struct Notification {
message_type: String,
message: String,
debounce: Option<Duration>,
}
impl Notification {
pub fn new(message_type: String, message: String, debounce: Option<Duration>) -> Self {
Notification {
message_type,
message,
debounce,
}
}
}
struct NotificationStatus {
message: String,
needs_sending: bool,
last_sent: Option<Instant>,
last_attempt: Option<Instant>,
failed_since_last_success: u32,
}
pub struct NotificationService {
url: Option<String>,
tx: mpsc::Sender<Notification>,
rx: mpsc::Receiver<Notification>,
}
impl NotificationService {
pub fn new(url: Option<String>) -> Self {
let (tx, rx) = mpsc::channel(10);
Self { url, tx, rx }
}
pub fn new_handler(&self) -> mpsc::Sender<Notification> {
self.tx.clone()
}
}
pub fn run_notification_worker(
task_tracker: &TaskTracker,
mut notification_service: NotificationService,
) {
task_tracker.spawn(async move {
if let Some(url) = notification_service.url
&& !url.is_empty()
{
let mut notification_statuses = HashMap::new();
let http_client = reqwest::Client::new();
loop {
// Get any notifications since the last time we checked
loop {
match notification_service.rx.try_recv() {
Ok(notification) => {
let status = notification_statuses
.entry(notification.message_type)
.or_insert_with(|| NotificationStatus {
message: "".to_string(),
needs_sending: true,
last_sent: None,
last_attempt: None,
failed_since_last_success: 0,
});
// Ignore if we're in the debounce period
if let Some(debounce) = notification.debounce
&& let Some(last_sent) = status.last_sent
&& last_sent.elapsed() < debounce
{
continue;
}
status.message = notification.message;
status.needs_sending = true;
}
Err(TryRecvError::Empty) => {
break;
}
Err(TryRecvError::Disconnected) => {
return;
}
}
}
// Attempt to send pending notifications
for notification in notification_statuses.values_mut() {
if !notification.needs_sending {
continue;
}
// Backoff retries, up to a maximum of 256 seconds.
if let Some(last_attempt) = notification.last_attempt {
let min_wait_time = Duration::from_secs(
2u64.pow(min(notification.failed_since_last_success, 8)),
);
if last_attempt.elapsed() < min_wait_time {
continue;
}
}
match http_client
.post(&url)
.body(notification.message.clone())
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
notification.last_sent = Some(Instant::now());
notification.failed_since_last_success = 0;
notification.needs_sending = false;
} else {
notification.failed_since_last_success += 1;
notification.last_attempt = Some(Instant::now());
}
}
Err(e) => {
error!("Failed to send notification to ntfy: {e}");
notification.failed_since_last_success += 1;
notification.last_attempt = Some(Instant::now());
}
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
// If there's no url to send to we'll just discard the notifications
else {
loop {
if notification_service.rx.recv().await.is_none() {
break;
}
}
}
});
}

View File

@@ -1,19 +1,18 @@
use crate::ServerState;
use anyhow::Error;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode;
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use futures::TryStreamExt;
use log::error;
use rayhunter::diag::DataType;
use rayhunter::gsmtap_parser;
use rayhunter::pcap::GsmtapPcapWriter;
use rayhunter::qmdl::QmdlReader;
use std::sync::Arc;
use std::{future, pin::pin};
use tokio::io::duplex;
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
@@ -21,12 +20,15 @@ use tokio_util::io::ReaderStream;
// 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>,
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 qmdl file with name {}", qmdl_name),
format!("couldn't find manifest entry with name {qmdl_name}"),
))?;
if entry.qmdl_size_bytes == 0 {
return Err((
@@ -38,39 +40,14 @@ pub async fn get_pcap(
let qmdl_file = qmdl_store
.open_entry_qmdl(entry_index)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
// the QMDL reader should stop at the last successfully written data chunk
// (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(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),
}
}
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
error!("failed to generate PCAP: {e:?}");
}
});
@@ -78,3 +55,39 @@ pub async fn get_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(())
}

View File

@@ -1,10 +1,14 @@
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 std::path::{Path, PathBuf};
use thiserror::Error;
use tokio::{
fs::{self, try_exists, File, OpenOptions},
fs::{self, File, OpenOptions, try_exists},
io::AsyncWriteExt,
};
@@ -47,7 +51,6 @@ pub struct ManifestEntry {
pub start_time: DateTime<Local>,
pub last_message_time: Option<DateTime<Local>>,
pub qmdl_size_bytes: usize,
pub analysis_size_bytes: usize,
pub rayhunter_version: Option<String>,
pub system_os: Option<String>,
pub arch: Option<String>,
@@ -62,7 +65,6 @@ impl ManifestEntry {
start_time: now,
last_message_time: None,
qmdl_size_bytes: 0,
analysis_size_bytes: 0,
rayhunter_version: Some(metadata.rayhunter_version),
system_os: Some(metadata.system_os),
arch: Some(metadata.arch),
@@ -136,6 +138,83 @@ impl RecordingStore {
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>,
@@ -200,7 +279,6 @@ impl RecordingStore {
.open(entry.get_analysis_filepath(&self.path))
.await
.map_err(RecordingStoreError::ReadFileError)?;
self.update_entry_analysis_size(entry_index, 0).await?;
Ok(file)
}
@@ -226,17 +304,9 @@ impl RecordingStore {
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> {
// 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
@@ -271,31 +341,43 @@ impl RecordingStore {
Some((entry_index, &self.manifest.entries[entry_index]))
}
pub async fn delete_entry(&mut self, name: &str) -> Result<ManifestEntry, RecordingStoreError> {
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)?;
if let Some(current_entry) = self.current_entry {
if current_entry == entry_to_delete_idx {
match self.current_entry {
Some(current_entry) if current_entry == entry_to_delete_idx => {
self.close_current_entry().await?;
} else {
}
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);
tokio::fs::remove_file(qmdl_filepath)
remove_file_if_exists(&qmdl_filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
tokio::fs::remove_file(analysis_filepath)
remove_file_if_exists(&analysis_filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
Ok(entry_to_delete)
Ok(())
}
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
@@ -303,22 +385,41 @@ impl RecordingStore {
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);
tokio::fs::remove_file(qmdl_filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
tokio::fs::remove_file(analysis_filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
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);
}
self.manifest.entries.drain(..);
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::*;
@@ -348,9 +449,11 @@ mod tests {
RecordingStore::read_manifest(dir.path()).await.unwrap(),
store.manifest
);
assert!(store.manifest.entries[entry_index]
.last_message_time
.is_none());
assert!(
store.manifest.entries[entry_index]
.last_message_time
.is_none()
);
store
.update_entry_qmdl_size(entry_index, 1000)

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

@@ -0,0 +1,371 @@
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::display::DisplayState;
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 ui_update_sender: Option<Sender<DisplayState>>,
}
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())
}
pub async fn debug_set_display_state(
State(state): State<Arc<ServerState>>,
Json(display_state): Json<DisplayState>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if let Some(ui_sender) = &state.ui_update_sender {
ui_sender.send(display_state).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to send display state update".to_string(),
)
})?;
Ok((
StatusCode::OK,
"display state updated successfully".to_string(),
))
} else {
Err((
StatusCode::SERVICE_UNAVAILABLE,
"display system not available".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
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)),
ui_update_sender: 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

@@ -3,10 +3,11 @@ use std::sync::Arc;
use crate::qmdl_store::ManifestEntry;
use crate::server::ServerState;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use log::error;
use rayhunter::{Device, util::RuntimeMetadata};
use serde::Serialize;
use tokio::process::Command;
@@ -14,13 +15,15 @@ use tokio::process::Command;
pub struct SystemStats {
pub disk_stats: DiskStats,
pub memory_stats: MemoryStats,
pub runtime_metadata: RuntimeMetadata,
}
impl SystemStats {
pub async fn new(qmdl_path: &str) -> Result<Self, String> {
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
Ok(Self {
disk_stats: DiskStats::new(qmdl_path).await?,
memory_stats: MemoryStats::new().await?,
disk_stats: DiskStats::new(qmdl_path, device).await?,
memory_stats: MemoryStats::new(device).await?,
runtime_metadata: RuntimeMetadata::new(),
})
}
}
@@ -37,13 +40,22 @@ pub struct DiskStats {
impl DiskStats {
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing
// the QMDL file
pub async fn new(qmdl_path: &str) -> Result<Self, String> {
let mut df_cmd = Command::new("df");
// the QMDL file.
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
// Uz801 needs to be told to use the busybox df specifically
let mut df_cmd: Command;
if matches!(device, Device::Uz801) {
df_cmd = Command::new("busybox");
df_cmd.arg("df");
} else {
df_cmd = Command::new("df");
}
df_cmd.arg("-h");
df_cmd.arg(qmdl_path);
let stdout = get_cmd_output(df_cmd).await?;
let mut parts = stdout.split_whitespace().skip(7).to_owned();
// Handle standard df -h format
let mut parts = stdout.split_whitespace().skip(7);
Ok(Self {
partition: parts.next().ok_or("error parsing df output")?.to_string(),
total_size: parts.next().ok_or("error parsing df output")?.to_string(),
@@ -80,9 +92,16 @@ async fn get_cmd_output(mut cmd: Command) -> Result<String, String> {
}
impl MemoryStats {
// runs "free -k" and parses the output to retrieve memory stats
pub async fn new() -> Result<Self, String> {
let mut free_cmd = Command::new("free");
// runs "free -k" and parses the output to retrieve memory stats for most devices,
pub async fn new(device: &Device) -> Result<Self, String> {
// Use busybox for Uz801
let mut free_cmd: Command;
if matches!(device, Device::Uz801) {
free_cmd = Command::new("busybox");
free_cmd.arg("free");
} else {
free_cmd = Command::new("free");
}
free_cmd.arg("-k");
let stdout = get_cmd_output(free_cmd).await?;
let mut numbers = stdout
@@ -99,7 +118,7 @@ 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);
return format!("{kb}K");
}
format!("{:.1}M", kb as f64 / 1024.0)
}
@@ -108,10 +127,10 @@ pub async fn get_system_stats(
State(state): State<Arc<ServerState>>,
) -> Result<Json<SystemStats>, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await;
match SystemStats::new(qmdl_store.path.to_str().unwrap()).await {
match SystemStats::new(qmdl_store.path.to_str().unwrap(), &state.config.device).await {
Ok(stats) => Ok(Json(stats)),
Err(err) => {
error!("error getting system stats: {}", err);
error!("error getting system stats: {err}");
Err((
StatusCode::INTERNAL_SERVER_ERROR,
"error getting system stats".to_string(),

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,24 @@
export class ActionError extends Error {
// The number of this an identical error has happened.
// This is shown as a number next to the error in the UI.
times = $state(1);
constructor(message: string, cause: Error) {
super(message);
this.cause = cause;
}
}
export const action_errors: ActionError[] = $state([]);
export function add_error(e: Error, msg: string): void {
for (const existing of action_errors) {
if (existing.message === msg) {
existing.times += 1;
return;
}
}
const action_error = new ActionError(msg, e);
action_errors.unshift(action_error);
console.log(action_errors.length);
}

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { AnalysisRowType, parse_finished_report } from './analysis.svelte';
import { type NewlineDeliminatedJson } from './ndjson';
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: 'Low',
message: 'Something nasty happened',
},
],
},
];
describe('analysis report parsing', () => {
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());
expect(event!.event_type).toEqual('Low');
} else {
throw 'wrong row type';
}
});
});

View File

@@ -0,0 +1,138 @@
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;
this.report_version = ndjson.report_version || 2; // Default to v2
}
}
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 EventType = 'Informational' | 'Low' | 'Medium' | 'High';
export type Event = {
event_type: EventType;
message: string;
} | null;
function get_event(event_json: any): Event {
if (!['Informational', 'Low', 'Medium', 'High'].includes(event_json.event_type)) {
throw `Invalid/unhandled event type: ${event_json.event_type}`;
}
return event_json;
}
function get_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.event_type === '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]);
const rows = get_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,62 @@
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> = $state(new Map());
public reports: Map<string, AnalysisReport | string> = $state(new Map());
public set_queued_status(name: string) {
this.status.set(name, AnalysisStatus.Queued);
this.reports.delete(name);
}
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,86 @@
<script lang="ts">
import { action_errors } from '../action_errors.svelte';
let pos = $state(0);
let current_error = $derived(action_errors[pos]);
function prev_error() {
if (pos > 0) pos -= 1;
else pos = action_errors.length - 1;
}
function next_error() {
if (pos + 1 < action_errors.length) pos += 1;
else pos = 0;
}
function clear_errors() {
pos = 0;
action_errors.length = 0;
}
</script>
{#if action_errors.length > 0}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2
border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
>
<div class="flex flex-row justify-between">
<span class="text-xl font-bold mb-2 mr-5 flex flex-row items-center gap-1 text-red-600">
<svg
class="w-6 h-6 text-red-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
clip-rule="evenodd"
/>
</svg>
Error Completing Action {current_error.times > 1 ? `x${current_error.times}` : ''}
</span>
<div class="flex items-center mb-2">
<span>{pos + 1}/{action_errors.length}</span>
<button title="previous error" aria-label="previous error" onclick={prev_error}>
<svg aria-hidden="true" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m 15.499979,19.499979 -6.9999997,-7 6.9999997,-6.9999997"
/>
</svg>
</button>
<button title="next error" aria-label="next error" onclick={next_error}>
<svg aria-hidden="true" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m 8.5000207,5.4999793 7.0000003,6.9999997 -7.0000003,7"
/>
</svg>
</button>
<button title="clear errors" aria-label="clear errors" onclick={clear_errors}>
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
<path
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
/>
</svg>
</button>
</div>
</div>
<span>{current_error.message}</span>
{#if current_error.cause}
<details>
<summary>Details</summary>
<code>{current_error.cause}</code>
</details>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,92 @@
<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.by(() => {
if (!ready) {
return 'text-gray-700';
} else if ((entry.get_num_warnings() || 0) < 1) {
return 'text-green-700 border-green-500 bg-green-200 text-blue-600 border rounded-full px-2';
} else {
return 'text-red-700 border-red-500 bg-red-200 text-blue-600 border rounded-full px-2';
}
});
</script>
<button class="flex flex-row gap-1 lg:gap-2" disabled={!ready} {onclick}>
<span class="flex flex-row items-center gap-1">
{#if entry.analysis_status === AnalysisStatus.Queued || entry.analysis_status === AnalysisStatus.Running || (entry.analysis_status === AnalysisStatus.Finished && entry.analysis_report === undefined)}
<svg
class="animate-spin h-4 w-4 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{/if}
<span class={button_class}>{summary}</span>
</span>
<svg
class="w-6 h-6 text-gray-800 transition-transform {analysis_visible ? 'rotate-180' : ''}"
aria-hidden="true"
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,102 @@
<script lang="ts">
import { AnalysisRowType, 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 as event, analyzerIndex}
{#if event !== null}
{@const analyzer = analyzers[analyzerIndex]}
{@const event_type_class = {
Informational: '',
Low: 'bg-yellow-200',
Medium: 'bg-orange-400',
High: 'bg-red-600',
}[event.event_type]}
<tr class="even:bg-gray-200 odd:bg-white">
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2 {event_type_class} text-center"
>{event.event_type}</td
>
</tr>
{/if}
{/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,53 @@
<script lang="ts">
import { type ReportMetadata } from '$lib/analysis.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import AnalysisTable from './AnalysisTable.svelte';
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
let {
entry,
manager,
current,
}: {
entry: ManifestEntry;
manager: AnalysisManager;
current: boolean;
} = $props();
</script>
<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 !current}
<div class="flex flex-row justify-end items-center">
<ReAnalyzeButton {entry} {manager} />
</div>
{/if}
{#if entry.analysis_report.rows.length > 0}
<AnalysisTable report={entry.analysis_report} />
{:else}
<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,97 @@
<script lang="ts">
import { user_action_req } from '$lib/utils.svelte';
let {
url,
method = 'POST',
label,
loadingLabel,
disabled = false,
variant = 'blue',
icon,
onclick,
ariaLabel,
errorMessage,
}: {
url: string;
method?: string;
label: string;
loadingLabel?: string;
disabled?: boolean;
variant?: 'blue' | 'red' | 'green';
icon?: any; // Svelte snippet
onclick?: () => void | Promise<void>;
ariaLabel?: string;
errorMessage?: string;
} = $props();
let is_requesting = $state(false);
let is_disabled = $derived(disabled || is_requesting);
const variantClasses = {
blue: {
enabled: 'bg-blue-500 hover:bg-blue-700',
disabled: 'bg-blue-500 opacity-50 cursor-not-allowed',
},
red: {
enabled: 'bg-red-500 hover:bg-red-700',
disabled: 'bg-red-500 opacity-50 cursor-not-allowed',
},
green: {
enabled: 'bg-green-500 hover:bg-green-700',
disabled: 'bg-green-500 opacity-50 cursor-not-allowed',
},
};
async function handleClick() {
if (is_disabled) return;
is_requesting = true;
try {
await user_action_req(
method,
url,
errorMessage ? errorMessage : 'Error performing action'
);
if (onclick) {
await onclick();
}
} catch (err) {
console.error(`Failed to ${method} ${url}:`, err);
alert(`Request failed. Please try again.`);
} finally {
is_requesting = false;
}
}
let buttonClasses = $derived(
is_disabled ? variantClasses[variant].disabled : variantClasses[variant].enabled
);
</script>
<button
class="text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row items-center gap-1 {buttonClasses}"
onclick={handleClick}
disabled={is_disabled}
aria-label={ariaLabel || label}
>
<span>{is_requesting && loadingLabel ? loadingLabel : label}</span>
{#if is_requesting}
<svg
class="w-4 h-4 text-white animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else if icon}
{@render icon()}
{/if}
</button>

View File

@@ -0,0 +1,282 @@
<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>
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
ntfy URL for Sending Notifications
</label>
<input
id="ntfy_url"
type="url"
bind:value={config.ntfy_url}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
</div>
<div class="space-y-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="incomplete_sib" class="ml-2 block text-sm text-gray-700">
Incomplete SIB Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="test_analyzer"
type="checkbox"
bind:checked={config.analyzers.test_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
Test Heuristic (noisey!)
</label>
</div>
</div>
</div>
<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,12 @@
<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`}
name="all recodings"
/>
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { user_action_req } from '$lib/utils.svelte';
let {
text,
url,
prompt,
name,
}: {
text?: string;
url: string;
prompt: string;
name: string;
} = $props();
function confirmDelete() {
if (window.confirm(prompt)) {
user_action_req('POST', url, 'Unable to delete recording ' + name);
}
}
</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,23 @@
<script lang="ts">
let {
url,
text,
full_button = false,
}: {
url: string;
text: string;
full_button?: boolean;
} = $props();
</script>
<a
href={url}
class="flex flex-row {full_button
? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md'
: 'text-blue-600 underline'}"
>
{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>
</a>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from '$lib/components/DeleteButton.svelte';
import AnalysisStatus from './AnalysisStatus.svelte';
import AnalysisView from './AnalysisView.svelte';
import RecordingControls from './RecordingControls.svelte';
let {
entry,
current,
server_is_recording,
manager,
}: {
entry: ManifestEntry;
current: boolean;
server_is_recording: boolean;
manager: AnalysisManager;
} = $props();
// passing `undefined` as the locale uses the browser default
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()}
name={entry.name}
/>
{/if}
</div>
<div class="border-b {analysis_visible ? '' : 'hidden'}">
<AnalysisView {entry} {manager} {current} />
</div>
</div>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import TableRow from './ManifestTableRow.svelte';
import Card from './ManifestCard.svelte';
interface Props {
entries: ManifestEntry[];
server_is_recording: boolean;
manager: AnalysisManager;
}
let { entries, server_is_recording, manager }: 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">Download</th>
<th class="p-2" scope="col">Analysis</th>
<th class="p-2" scope="col"></th>
</tr>
</thead>
<tbody>
{#each entries as entry, i}
<TableRow {entry} current={false} {i} {manager} />
{/each}
</tbody>
</table>
<!--For smaller screens we use cards-->
<div class="lg:hidden flex flex-col gap-4">
{#each entries as entry}
<Card {entry} current={false} {server_is_recording} {manager} />
{/each}
</div>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from '$lib/components/DeleteButton.svelte';
import AnalysisStatus from './AnalysisStatus.svelte';
import AnalysisView from './AnalysisView.svelte';
let {
entry,
current,
i,
manager,
}: {
entry: ManifestEntry;
current: boolean;
i: number;
manager: AnalysisManager;
} = $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">
<div class="flex flex-row gap-2">
<DownloadLink url={entry.get_pcap_url()} text="pcap" />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" />
<DownloadLink url={entry.get_zip_url()} text="zip" />
</div>
</td>
<td class="p-2"
><AnalysisStatus onclick={toggle_analysis_visibility} {entry} {analysis_visible} /></td
>
{#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()}
name={entry.name}
/>
</td>
{/if}
</tr>
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
<td class="border-t border-dashed p-2" colspan="9">
<AnalysisView {entry} {manager} {current} />
</td>
</tr>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import ApiRequestButton from './ApiRequestButton.svelte';
import { AnalysisStatus, AnalysisManager } from '$lib/analysisManager.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte';
let {
entry,
manager,
}: {
entry: ManifestEntry;
manager: AnalysisManager;
} = $props();
let url = $derived(entry.get_reanalyze_url());
let entry_name = $derived(entry.name);
let analysis_status = $derived(entry.analysis_status);
let is_processing = $derived(
analysis_status === AnalysisStatus.Queued || analysis_status === AnalysisStatus.Running
);
async function handleReAnalyze() {
// Update the entry directly for immediate UI feedback
entry.analysis_status = AnalysisStatus.Queued;
entry.analysis_report = undefined;
manager.set_queued_status(entry_name);
}
</script>
<ApiRequestButton
{url}
label="Re-analyze"
loadingLabel="Analyzing..."
disabled={is_processing}
variant="blue"
onclick={handleReAnalyze}
ariaLabel="re-analyze"
errorMessage="Error re-analyzing recoding"
>
{#snippet icon()}
<svg style="width:20px;height:20px" viewBox="0 0 24 24">
<path
fill="white"
d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z"
/>
</svg>
{/snippet}
</ApiRequestButton>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import ApiRequestButton from './ApiRequestButton.svelte';
let {
server_is_recording,
}: {
server_is_recording: boolean;
} = $props();
</script>
<div>
{#if server_is_recording}
<ApiRequestButton
url="/api/stop-recording"
label="Stop"
variant="red"
errorMessage="Error stoppping recording"
>
{#snippet icon()}
<svg
class="w-6 h-6 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z"
/>
</svg>
{/snippet}
</ApiRequestButton>
{:else}
<ApiRequestButton
url="/api/start-recording"
label="Start"
variant="blue"
errorMessage="Error starting recording"
>
{#snippet icon()}
<svg
class="w-6 h-6 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M8.6 5.2A1 1 0 0 0 7 6v12a1 1 0 0 0 1.6.8l8-6a1 1 0 0 0 0-1.6l-8-6Z"
clip-rule="evenodd"
/>
</svg>
{/snippet}
</ApiRequestButton>
{/if}
</div>

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,109 @@
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}`;
}
get_reanalyze_url(): string {
return `/api/analysis/${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,78 @@
import { add_error } from './action_errors.svelte';
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;
test_analyzer: boolean;
}
export interface Config {
ui_level: number;
colorblind_mode: boolean;
key_input_mode: number;
ntfy_url: string;
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);
}
}
// A wrapper around req that reports errors to the UI
export async function user_action_req(
method: string,
url: string,
error_msg: string
): Promise<string | undefined> {
try {
return await req(method, url);
} catch (error) {
if (error instanceof Error) {
console.log('beeeo');
add_error(error, error_msg);
}
return undefined;
}
}
export async function get_manifest(): Promise<Manifest> {
const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest'));
return new Manifest(manifest_json);
}
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,189 @@
<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';
import ActionErrors from '$lib/components/ActionErrors.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);
let update_error: string | undefined = $state(undefined);
$effect(() => {
const interval = setInterval(async () => {
try {
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();
update_error = undefined;
loaded = true;
} catch (error) {
if (error instanceof Error) {
update_error = error.message;
} else {
update_error = '';
}
}
}, 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 update_error !== undefined}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
>
<span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
<svg
class="w-8 h-8 text-red-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
clip-rule="evenodd"
/>
</svg>
Connection Error
</span>
<span
>This webpage is not currently receiving updates from your Rayhunter device. This
could be do loss of connection or some issue with your device.</span
>
{#if update_error}
<details>
<summary>Error</summary>
<code>{update_error}</code>
</details>
{/if}
</div>
{/if}
<ActionErrors />
{#if loaded}
<div class="flex flex-col lg:flex-row gap-4">
{#if current_entry}
<Card
entry={current_entry}
current={true}
server_is_recording={!!current_entry}
{manager}
/>
{:else}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
>
<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} {manager} />
</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
}

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